From ad961227f57a217655c9e05e5987e6d0e5524409 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Sun, 5 Jul 2026 05:58:20 -0500
Subject: [PATCH] feat(secrets): add user-specific runtime secrets (#8825)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs often need provider credentials, API tokens, and other
environment-bound secrets.
> - Company-level secrets work for shared credentials, but they do not
model values that should differ by human operator.
> - Without a user-scoped model, a run can dispatch without knowing
whether the responsible human has supplied the needed value.
> - Paperclip also needs run attribution to make those user-scoped
runtime checks deterministic and auditable.
> - This pull request adds user-specific secret definitions, per-user
values, environment bindings, responsible-user attribution, and runtime
resolution gates.
> - The benefit is that teams can define the secret once, let each user
provide their own value, and block runs before dispatch when required
user secrets or active definitions are unavailable.
## Linked Issues or Issue Description
Refs #224
Refs #6057
This PR implements user-specific secret support as a core
secret-management capability rather than a one-off adapter setting. It
is related to existing public work on company secrets UI and runtime
secret refs, but is distinct because the value is owned by the
responsible user and resolved at run dispatch time.
Related PR search before opening found existing secrets work such as
#1550, #8256, #8614, #8634, and #8647; none of those add the full
user-secret definition/value/runtime gate covered here.
## What Changed
- Added user-secret definitions and per-user "My secrets" values,
keeping stored values out of access metadata.
- Added `user_secret_ref` environment bindings and UI affordances to
pick them alongside existing secret refs.
- Added responsible-user runtime resolution so user-secret refs resolve
against the human responsible for the run.
- Added pre-dispatch missing-secret gates so runs fail before adapter
dispatch when required user values are absent or definitions are
inactive.
- Added low-trust allowlist hardening for user-secret runtime access.
- Added issue, routine, run, and agent API key responsible-user
attribution and fail-closed dispatch behavior when attribution cannot be
resolved.
- Added denial-copy mapping so responsible-user authorization failures
surface as actionable run outcomes instead of opaque setup failures.
- Added OpenAPI documentation for the user-secret routes.
- Rebases cleanly on current `master`; migrations were renumbered
incrementally as `0128_user_specific_secrets`,
`0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant` after upstream `0126`/`0127`
migrations.
- Removed previously committed local design screenshots so the PR
contains code/docs/tests only.
## Verification
- PASS: PR head `2527febd106bcf3ca264ca0da7fca491084192d6` is based on
`paperclipai/paperclip:master`.
- PASS: `git diff --check`
- PASS: `git diff --name-only public/master...HEAD | rg
'^(pnpm-lock\\.yaml|\\.github/workflows/|screenshots/)' || true`
produced no files.
- PASS: migration journal audit confirmed unique indexes through `130`
with tail entries `0126_issue_comment_derived_attribution`,
`0127_environment_custom_images_instance_scoped`,
`0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant`.
- PASS: `pnpm --filter @paperclipai/ui typecheck`
- PASS: `pnpm --filter @paperclipai/server typecheck`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-responsible-user-invariant.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-active-run-output-watchdog.test.ts
src/__tests__/heartbeat-stale-queue-invalidation.test.ts
src/__tests__/heartbeat-workspace-finalize-branch.test.ts
src/__tests__/issue-monitor-scheduler.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-comment-wake-batching.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
src/__tests__/heartbeat-plugin-environment.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/low-trust-red-team-routes.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/secrets-service.test.ts` (55 tests)
- PASS: `pnpm vitest run server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts` (89 tests after final
Greptile cleanup fixes)
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (17 tests
after the final rebase CI fix)
- PASS: focused server Vitest batches covering heartbeat recovery,
project env, plugin env, routines, low-trust, pipelines, monitors,
watchdog, and stale queue paths.
- PASS: GitHub checks are green on
`2527febd106bcf3ca264ca0da7fca491084192d6`, including Typecheck +
Release Registry, Build, General tests, serialized server suites, e2e,
Canary Dry Run, verify, security checks, and Greptile Review.
- PASS: Greptile Review completed successfully on
`2527febd106bcf3ca264ca0da7fca491084192d6` with Confidence Score 5/5,
and GraphQL review-thread audit returned zero unresolved non-outdated
threads.
## Risks
- Runtime behavior now depends on a run having a correct responsible
user; missing or incorrect responsibility assignment can block runs
before adapter dispatch.
- `user_secret_ref` bindings intentionally expose metadata without
values, but UI/API callers may need to handle the new binding kind
explicitly.
- External secret providers and IAM policies are not automatically
provisioned by this PR; operators still need to configure provider-side
access for non-local vaults.
- The PR is broad across db/shared/server/UI/runtime paths, so release
validation should include both API and UI secret workflows before merge.
- The migration renumbering is intentionally incremental after upstream
migrations; the branch migrations use guarded
column/table/index/constraint creation so users who tested the older
draft numbering should not hit duplicate DDL for the existing objects.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
OpenAI Codex, GPT-5-based coding agent (`gpt-5`), Codex local adapter
with shell/tool use and code execution. Context window and internal
reasoning mode are not exposed by the runtime.
## 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
Co-authored-by: Claude Opus 4.8
---
cli/src/__tests__/company-delete.test.ts | 1 +
cli/src/__tests__/secrets.test.ts | 3 +
doc/DATABASE.md | 14 +
doc/SECRETS-AWS-PROVIDER.md | 45 +-
docs/api/secrets.md | 24 +
docs/deploy/secrets.md | 123 +-
.../codex-local/src/server/codex-home.test.ts | 126 ++
.../codex-local/src/server/codex-home.ts | 72 +
.../codex-local/src/server/execute.ts | 19 +-
.../adapters/codex-local/src/server/index.ts | 4 +
.../migrations/0128_user_specific_secrets.sql | 178 ++
.../0129_agent_api_key_responsible_user.sql | 41 +
.../0130_run_responsible_user_invariant.sql | 165 ++
packages/db/src/migrations/meta/_journal.json | 21 +
packages/db/src/schema/agent_api_keys.ts | 1 +
packages/db/src/schema/companies.ts | 1 +
packages/db/src/schema/company_secrets.ts | 37 +-
packages/db/src/schema/heartbeat_runs.ts | 6 +
packages/db/src/schema/index.ts | 2 +
packages/db/src/schema/issues.ts | 2 +
packages/db/src/schema/routines.ts | 14 +
.../db/src/schema/secret_access_events.ts | 18 +-
.../db/src/schema/user_secret_declarations.ts | 40 +
.../db/src/schema/user_secret_definitions.ts | 37 +
.../plugin-llm-wiki/tests/plugin.spec.ts | 1 +
packages/plugins/sdk/src/testing.ts | 2 +
packages/shared/src/api.ts | 5 +
packages/shared/src/constants.ts | 13 +-
packages/shared/src/index.ts | 29 +
packages/shared/src/issue-attribution.test.ts | 95 +
packages/shared/src/issue-attribution.ts | 57 +
.../src/responsible-user-denial.test.ts | 80 +
.../shared/src/responsible-user-denial.ts | 97 +
packages/shared/src/types/company.ts | 1 +
packages/shared/src/types/heartbeat.ts | 1 +
packages/shared/src/types/index.ts | 5 +
packages/shared/src/types/issue.ts | 1 +
packages/shared/src/types/routine.ts | 2 +
packages/shared/src/types/secrets.ts | 67 +-
packages/shared/src/validators/company.ts | 1 +
packages/shared/src/validators/index.ts | 12 +
packages/shared/src/validators/issue.test.ts | 18 +
packages/shared/src/validators/issue.ts | 8 +-
packages/shared/src/validators/routine.ts | 1 +
packages/shared/src/validators/secret.test.ts | 89 +
packages/shared/src/validators/secret.ts | 127 +-
server/src/__tests__/agent-auth-jwt.test.ts | 3 +-
.../__tests__/agent-auth-middleware.test.ts | 304 +++
.../__tests__/authorization-service.test.ts | 216 ++
.../__tests__/authz-company-access.test.ts | 88 +
server/src/__tests__/error-handler.test.ts | 48 +-
server/src/__tests__/file-resources.test.ts | 6 +-
...at-accepted-plan-workspace-refresh.test.ts | 10 +
...artbeat-active-run-output-watchdog.test.ts | 1 +
.../heartbeat-comment-wake-batching.test.ts | 22 +
.../heartbeat-dependency-scheduling.test.ts | 16 +
...eartbeat-issue-liveness-escalation.test.ts | 9 +
.../heartbeat-local-environment.test.ts | 1 +
.../heartbeat-plugin-environment.test.ts | 5 +
.../heartbeat-process-recovery.test.ts | 15 +
.../__tests__/heartbeat-project-env.test.ts | 237 ++-
...artbeat-responsible-user-invariant.test.ts | 306 +++
.../heartbeat-retry-scheduling.test.ts | 14 +
.../heartbeat-runtime-skills.test.ts | 1 +
...heartbeat-stale-queue-invalidation.test.ts | 1 +
...eartbeat-workspace-finalize-branch.test.ts | 1 +
...ue-agent-mutation-ownership-routes.test.ts | 159 +-
.../__tests__/issue-monitor-scheduler.test.ts | 1 +
.../issue-thread-interactions-service.test.ts | 9 +
server/src/__tests__/issues-service.test.ts | 99 +
.../low-trust-red-team-routes.test.ts | 7 +
.../src/__tests__/pipelines-service.test.ts | 1 +
.../__tests__/plugin-managed-routines.test.ts | 1 +
.../__tests__/qa-routine-secrets-e2e.test.ts | 1 +
.../__tests__/routine-run-telemetry.test.ts | 1 +
server/src/__tests__/routines-service.test.ts | 49 +
server/src/__tests__/secrets-routes.test.ts | 175 ++
server/src/__tests__/secrets-service.test.ts | 624 ++++++
server/src/__tests__/setup-supertest.ts | 9 +
server/src/agent-auth-jwt.ts | 16 +-
server/src/app.ts | 1 +
server/src/errors.ts | 4 +-
server/src/middleware/auth.ts | 178 +-
server/src/middleware/error-handler.ts | 36 +
server/src/redaction.ts | 10 +
server/src/routes/access.ts | 9 +-
server/src/routes/agents.ts | 18 +-
server/src/routes/authz.ts | 47 +-
server/src/routes/board-chat.ts | 5 +-
server/src/routes/companies.ts | 5 +-
server/src/routes/issues.ts | 123 +-
server/src/routes/openapi.ts | 122 ++
server/src/routes/pipelines.ts | 4 +-
server/src/routes/secrets.ts | 345 +++
server/src/services/activity.ts | 1 +
server/src/services/agent-secret-bindings.ts | 74 +
server/src/services/agents.ts | 12 +-
server/src/services/authorization.ts | 228 +-
server/src/services/companies.ts | 1 +
server/src/services/heartbeat.ts | 411 +++-
server/src/services/issues.ts | 75 +
server/src/services/pipelines.ts | 1 +
server/src/services/plugin-host-services.ts | 2 +
...sponsible-user-denial-run-outcomes.test.ts | 92 +
.../responsible-user-denial-run-outcomes.ts | 75 +
server/src/services/routines.ts | 83 +
server/src/services/secrets.ts | 1287 ++++++++++-
server/src/types/express.d.ts | 6 +
tests/e2e/signoff-policy.spec.ts | 6 +
ui/src/App.tsx | 2 +
ui/src/api/activity.ts | 1 +
ui/src/api/secrets.ts | 76 +
ui/src/components/ActivityCharts.test.tsx | 1 +
ui/src/components/AgentConfigForm.tsx | 11 +
ui/src/components/CommentThread.tsx | 10 +-
ui/src/components/EnvVarEditor.test.tsx | 127 ++
ui/src/components/FileTree.tsx | 2 +-
ui/src/components/Identity.tsx | 6 +-
.../components/InlineEntitySelector.test.tsx | 20 +-
.../components/IssueAssignedBacklogNotice.tsx | 4 +-
ui/src/components/IssueBlockedNotice.tsx | 6 +-
ui/src/components/IssueChatThread.test.tsx | 18 +-
ui/src/components/IssueChatThread.tsx | 14 +-
ui/src/components/IssueColumns.test.tsx | 109 +-
ui/src/components/IssueColumns.tsx | 69 +-
.../components/IssueDocumentsSection.test.tsx | 1 +
ui/src/components/IssueFiltersPopover.tsx | 4 +-
ui/src/components/IssueLinkQuicklook.test.tsx | 1 +
.../IssueMonitorActivityCard.test.tsx | 1 +
ui/src/components/IssueProperties.test.tsx | 163 +-
ui/src/components/IssueRow.test.tsx | 1 +
ui/src/components/IssueRunLedger.test.tsx | 79 +
ui/src/components/IssueRunLedger.tsx | 27 +
.../components/IssueThreadInteractionCard.tsx | 4 +-
ui/src/components/IssueWorkspaceCard.test.tsx | 1 +
ui/src/components/IssuesList.test.tsx | 1 +
ui/src/components/IssuesList.tsx | 18 +-
ui/src/components/KanbanBoard.test.tsx | 1 +
ui/src/components/NewIssueDialog.test.tsx | 76 +-
ui/src/components/NewIssueDialog.tsx | 58 +-
ui/src/components/ProjectProperties.tsx | 9 +
.../ResponsibleUserDenialNotice.test.tsx | 52 +
.../ResponsibleUserDenialNotice.tsx | 59 +
ui/src/components/RoutineHistoryTab.test.tsx | 11 +
ui/src/components/StageSecretsPanel.tsx | 2 +-
.../EnvironmentVariablesEditor.test.tsx | 11 +-
.../environment-variables-editor/Row.tsx | 109 +-
.../environment-variables-editor/index.tsx | 49 +-
.../model.test.ts | 88 +-
.../environment-variables-editor/model.ts | 83 +-
.../InterruptHandoffViews.tsx | 4 +-
.../issue-properties/IssueProperties.tsx | 58 +-
.../routine-sections/editable-sections.tsx | 10 +-
ui/src/components/ui/avatar.tsx | 8 +-
ui/src/context/CompanyContext.test.tsx | 1 +
.../issueThreadInteractionFixtures.ts | 12 +-
ui/src/fixtures/systemNoticeFixtures.ts | 2 +-
ui/src/lib/activity-format.ts | 2 +-
ui/src/lib/assignees.test.ts | 7 +
ui/src/lib/assignees.ts | 8 +
ui/src/lib/inbox.test.ts | 14 +-
ui/src/lib/inbox.ts | 1 +
ui/src/lib/interrupt-handoff.test.ts | 2 +-
ui/src/lib/interrupt-handoff.ts | 14 +-
ui/src/lib/issue-filters.test.ts | 1 +
ui/src/lib/issue-tree.test.ts | 1 +
ui/src/lib/issueDetailBreadcrumb.test.ts | 1 +
ui/src/lib/issueDetailCache.test.ts | 1 +
ui/src/lib/issueDetailQuery.test.tsx | 1 +
ui/src/lib/optimistic-issue-comments.test.ts | 4 +
ui/src/lib/pipeline-liveness.ts | 4 +-
ui/src/lib/queryKeys.ts | 4 +
ui/src/lib/recent-selections.test.ts | 2 +-
ui/src/lib/subIssueDefaults.test.ts | 1 +
ui/src/lib/system-notice-comment.test.ts | 4 +-
ui/src/lib/work-mode-meta.ts | 2 +-
ui/src/lib/workspace-routines.test.ts | 1 +
ui/src/pages/AgentDetail.tsx | 35 +
ui/src/pages/DesignGuide.tsx | 10 +-
ui/src/pages/Inbox.test.tsx | 2 +
ui/src/pages/Inbox.tsx | 12 +-
ui/src/pages/IssueDetail.test.tsx | 137 +-
ui/src/pages/IssueDetail.tsx | 143 +-
ui/src/pages/Pipelines.tsx | 2 +-
ui/src/pages/ResponsibleUserDenialUxLab.tsx | 235 +++
ui/src/pages/Routines.test.tsx | 2 +
ui/src/pages/Routines.tsx | 10 +-
ui/src/pages/Secrets.render.test.tsx | 345 ++-
ui/src/pages/Secrets.test.ts | 101 +-
ui/src/pages/Secrets.tsx | 1874 ++++++++++++++---
.../secrets/ImportFromVaultDialog.test.tsx | 3 +
.../secrets/MissingUserSecretsBanner.test.tsx | 176 ++
.../secrets/MissingUserSecretsBanner.tsx | 106 +
ui/src/pages/secrets/MyUserSecretsTab.tsx | 181 ++
.../pages/secrets/SetMyUserSecretDialog.tsx | 174 ++
.../secrets/UserSecretDefinitionsTab.tsx | 386 ++++
ui/src/pages/secrets/my-value-state.ts | 20 +
.../secrets/user-secret-presentation.test.ts | 87 +
.../secrets/user-secret-presentation.tsx | 79 +
ui/src/plugins/bridge-init.ts | 8 +-
ui/storybook/fixtures/paperclipData.ts | 34 +
.../stories/agent-management.stories.tsx | 6 +
.../stories/data-viz-misc.stories.tsx | 1 +
.../stories/document-annotations.stories.tsx | 1 +
.../environment-variables-editor.stories.tsx | 3 +
.../stories/forms-editors.stories.tsx | 6 +
.../stories/issue-management.stories.tsx | 59 +-
.../stories/routine-detail-c.stories.tsx | 1 +
.../stories/routine-secrets.stories.tsx | 2 +
ui/storybook/stories/secrets.stories.tsx | 79 +-
ui/storybook/stories/user-secrets.stories.tsx | 209 ++
211 files changed, 12806 insertions(+), 701 deletions(-)
create mode 100644 packages/db/src/migrations/0128_user_specific_secrets.sql
create mode 100644 packages/db/src/migrations/0129_agent_api_key_responsible_user.sql
create mode 100644 packages/db/src/migrations/0130_run_responsible_user_invariant.sql
create mode 100644 packages/db/src/schema/user_secret_declarations.ts
create mode 100644 packages/db/src/schema/user_secret_definitions.ts
create mode 100644 packages/shared/src/issue-attribution.test.ts
create mode 100644 packages/shared/src/issue-attribution.ts
create mode 100644 packages/shared/src/responsible-user-denial.test.ts
create mode 100644 packages/shared/src/responsible-user-denial.ts
create mode 100644 server/src/__tests__/agent-auth-middleware.test.ts
create mode 100644 server/src/__tests__/heartbeat-responsible-user-invariant.test.ts
create mode 100644 server/src/services/responsible-user-denial-run-outcomes.test.ts
create mode 100644 server/src/services/responsible-user-denial-run-outcomes.ts
create mode 100644 ui/src/components/EnvVarEditor.test.tsx
create mode 100644 ui/src/components/ResponsibleUserDenialNotice.test.tsx
create mode 100644 ui/src/components/ResponsibleUserDenialNotice.tsx
create mode 100644 ui/src/pages/ResponsibleUserDenialUxLab.tsx
create mode 100644 ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx
create mode 100644 ui/src/pages/secrets/MissingUserSecretsBanner.tsx
create mode 100644 ui/src/pages/secrets/MyUserSecretsTab.tsx
create mode 100644 ui/src/pages/secrets/SetMyUserSecretDialog.tsx
create mode 100644 ui/src/pages/secrets/UserSecretDefinitionsTab.tsx
create mode 100644 ui/src/pages/secrets/my-value-state.ts
create mode 100644 ui/src/pages/secrets/user-secret-presentation.test.ts
create mode 100644 ui/src/pages/secrets/user-secret-presentation.tsx
create mode 100644 ui/storybook/stories/user-secrets.stories.tsx
diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts
index 8865585e75..d45fc12022 100644
--- a/cli/src/__tests__/company-delete.test.ts
+++ b/cli/src/__tests__/company-delete.test.ts
@@ -23,6 +23,7 @@ function makeCompany(overrides: Partial): Company {
brandColor: null,
logoAssetId: null,
logoUrl: null,
+ defaultResponsibleUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
diff --git a/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts
index 89ea984009..295a8e5e03 100644
--- a/cli/src/__tests__/secrets.test.ts
+++ b/cli/src/__tests__/secrets.test.ts
@@ -46,6 +46,9 @@ function secret(partial: Partial): CompanySecret {
return {
id: "secret-1",
companyId: "company-1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "agent_agent-12_anthropic_api_key",
name: "agent_agent-12_anthropic_api_key",
provider: "local_encrypted",
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index 43dfc05a8a..d4dffb37ce 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -174,11 +174,21 @@ up separately when you need full instance disaster recovery.
Paperclip stores secret metadata and versions in:
+- `user_secret_definitions`
+- `user_secret_declarations`
- `company_secrets`
- `company_secret_versions`
- `company_secret_bindings`
- `secret_access_events`
+Company secrets use `company_secrets.scope = 'company'` and are bound directly
+through `company_secret_bindings`. User-specific secrets reuse the same provider
+and version storage, but each value is a `company_secrets.scope = 'user'` row
+with `owner_user_id` and `user_secret_definition_id` set. Definitions describe
+the reusable company-level slot, declarations record where `user_secret_ref`
+bindings are required, and the concrete value is selected later for the
+responsible user.
+
Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`.
For local/default installs, the active provider is `local_encrypted`:
@@ -188,6 +198,10 @@ For local/default installs, the active provider is `local_encrypted`:
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
+- User-scoped values use the same local encrypted provider path. Database
+ backups preserve definitions, declarations, owner metadata, version metadata,
+ and access events, but restored user-scoped values are decryptable only when
+ the matching local master key is restored with the database.
Optional overrides:
diff --git a/doc/SECRETS-AWS-PROVIDER.md b/doc/SECRETS-AWS-PROVIDER.md
index c7cce82e57..4f150dccdf 100644
--- a/doc/SECRETS-AWS-PROVIDER.md
+++ b/doc/SECRETS-AWS-PROVIDER.md
@@ -4,7 +4,7 @@ Operational contract for the hosted `aws_secrets_manager` secret provider used b
## Scope
-- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
+- Hosted provider for Paperclip-managed company and user-scoped secrets when Paperclip Cloud runs on AWS.
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
@@ -120,6 +120,25 @@ Tag set for Paperclip-managed secrets:
- `paperclip:secret-key=`
- `paperclip:environment=`
+When the user-secret service creates Paperclip-managed AWS values, keep them
+under the same deployment/company namespace. The secret-key segment should be
+non-sensitive and collision-safe for the user-secret value, normally derived
+from the definition key plus an opaque credential-owner subject. Do not put
+emails, personal names, OAuth scopes, ticket identifiers, or plaintext
+credential material in AWS secret names, descriptions, or tags.
+
+For operator-owned external references, prefer a separate approved prefix such
+as:
+
+```text
+paperclip-ext///user-secrets//
+```
+
+Paperclip stores the external ref and provider version as metadata. Treat those
+fields as secret-adjacent: they must be redacted from comments, activity logs,
+run transcripts, issue documents, and broad board views unless a route is
+explicitly designed to show sanitized provider metadata.
+
## IAM And KMS Assumptions
Launch posture:
@@ -223,6 +242,14 @@ If selected external secrets use customer-managed KMS keys, also grant
permissions scoped to `paperclip//*`; do not broaden them for
remote import.
+The same rule applies to user-scoped external refs. Paperclip derives the
+responsible user and credential owner before resolution, but AWS IAM still
+decides whether the runtime role can read the selected ARN/path. If the runtime
+role can read a broad external prefix, that access is broader than Paperclip's
+metadata policy. Use dedicated provider vaults, accounts, Regions, prefixes,
+KMS keys, or runtime roles when provider-side isolation must match a
+user-secret boundary.
+
Safe scoping guidance:
- Prefer one Paperclip runtime role per environment/account.
@@ -288,16 +315,24 @@ Guidance:
What must survive:
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
+- User-secret definitions, declarations, `company_secrets.scope = 'user'`
+ value rows, owner user ids, responsible-user snapshots, access-event
+ metadata, and provider version refs.
- AWS Secrets Manager namespace under the configured deployment prefix.
+- Any operator-owned external AWS prefixes linked by user-scoped external refs.
- The configured KMS key and its decrypt permissions.
Restore checklist:
1. Restore Paperclip database metadata.
2. Confirm the same AWS Secrets Manager namespace still exists.
-3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
-4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
-5. Run the live smoke below or a targeted runtime secret resolution test.
+3. Confirm any linked user-scoped external prefixes still exist.
+4. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored
+ managed prefix and approved external prefixes.
+5. Confirm the role still has decrypt access to the CMKs referenced by
+ `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID` and by any external user-secret values.
+6. Run the live smoke below or a targeted runtime secret resolution test for
+ both a company secret and a user-secret value.
## Provider Outage Runbook
@@ -342,6 +377,8 @@ Response steps:
1. Stop or pause affected Paperclip runs.
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
+ For user-scoped incidents, include `credentialOwnerUserId`,
+ `responsibleUserId`, and `userSecretDefinitionId` in the review.
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
Region, deployment prefix, and approved external prefixes.
diff --git a/docs/api/secrets.md b/docs/api/secrets.md
index 93a56b6080..f1aad3b2a3 100644
--- a/docs/api/secrets.md
+++ b/docs/api/secrets.md
@@ -399,6 +399,30 @@ end at injection: the agent process can read, log, or forward the value, so
treat any secret bound to an agent as exposed to that agent. See the custody
boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries).
+User-specific env bindings use a definition key instead of a concrete
+`secretId`. The concrete value is resolved for the run's responsible user:
+
+```json
+{
+ "env": {
+ "GITHUB_TOKEN": {
+ "type": "user_secret_ref",
+ "key": "github_api_token",
+ "version": "latest",
+ "required": true,
+ "allowMissingOverride": false
+ }
+ }
+}
+```
+
+`required` defaults to `true` and `allowMissingOverride` defaults to `false`.
+Missing required user-secret values must fail closed before adapter dispatch.
+Optional missing values omit the environment variable; they must not inject an
+empty string or another user's value. Paperclip records value-free access
+events with `secretScope`, `responsibleUserId`, `credentialOwnerUserId`, and
+`userSecretDefinitionId`.
+
## Portability
Company export/import APIs represent agent and project environment requirements
diff --git a/docs/deploy/secrets.md b/docs/deploy/secrets.md
index 41fa9df375..0d6591574a 100644
--- a/docs/deploy/secrets.md
+++ b/docs/deploy/secrets.md
@@ -51,6 +51,114 @@ Project env applies to every issue run in that project. When a project env key
matches an agent env key, the project value wins before Paperclip injects its
own `PAPERCLIP_*` runtime variables.
+## User-Specific Secrets
+
+User-specific secrets let a shared agent or project declare a slot such as
+`github_api_token`, then resolve the value owned by the run's responsible user
+at dispatch time. The environment binding stores only the definition key:
+
+```json
+{
+ "env": {
+ "GITHUB_TOKEN": {
+ "type": "user_secret_ref",
+ "key": "github_api_token",
+ "required": true,
+ "allowMissingOverride": false
+ }
+ }
+}
+```
+
+Paperclip stores the feature as value-free metadata plus the user's own secret
+value record:
+
+- `user_secret_definitions`: company-level metadata for the reusable slot.
+- `user_secret_declarations`: target/config-path declarations for
+ `user_secret_ref` bindings, including required/optional policy.
+- `company_secrets` rows with `scope = "user"`, `owner_user_id`, and
+ `user_secret_definition_id`: the current user's actual value record.
+- `company_secret_versions`: encrypted or provider-backed version metadata for
+ the value record.
+
+Board/admin operators manage definitions and coverage. Individual users manage
+their own values. Board/admin coverage views must stay metadata-only: they can
+show missing, configured, inactive, provider, and vault status, but not
+plaintext values, raw external refs, provider credentials, or provider error
+payloads.
+
+Required user-secret refs fail closed when the responsible user is missing,
+the definition is missing, or the responsible user has no active value.
+Optional refs may omit the env var. They must not inject blank credentials or
+fall back to another user's value.
+
+### Vault Placement
+
+User-scoped values can use the same provider families as company secrets:
+
+- `local_encrypted`: the default local path. It is appropriate for local
+ trusted installs and small self-hosted deployments. The same master key
+ protects both company and user-scoped values.
+- `aws_secrets_manager`: the hosted/provider-vault path. Use it when the
+ deployment already relies on AWS Secrets Manager, KMS, CloudTrail, and
+ infrastructure IAM for custody.
+- Dedicated provider vaults: optional. Use a dedicated vault only when you need
+ a separate AWS account, Region, KMS key, prefix, retention posture, or
+ import boundary for user-owned values. Do not create one vault per user by
+ default; prefer one vault per environment or compliance boundary.
+
+A user-secret definition can carry provider and vault defaults. A user's value
+may also carry provider/vault metadata when the implementation path supports
+overrides. In both cases, provider vault config contains non-secret routing
+metadata only. Provider credentials still come from deployment infrastructure
+identity, not from Paperclip secrets.
+
+### External Reference Naming
+
+External vault references are secret-adjacent metadata. Treat paths, ARNs,
+versions, aliases, and tags as operationally sensitive even though they are not
+plaintext secret values.
+
+Recommended external ref shape for operator-owned AWS paths:
+
+```text
+paperclip-ext/{environment}/{company-id}/user-secrets/{definition-key}/{opaque-owner-id}
+```
+
+Guidance:
+
+- Use the user-secret definition key for the credential type, such as
+ `github_api_token`.
+- Use an opaque stable user id or one-way mapped subject id for the owner
+ segment. Avoid emails, personal names, customer names, OAuth scopes, or
+ ticket identifiers in provider paths.
+- Keep provider metadata value-free. Safe metadata examples are provider id,
+ vault id, Region, KMS key id or alias, tag counts, and fingerprint hashes.
+ Do not store raw AWS descriptions, full tag maps, token scopes, provider
+ error bodies, or anything copied from the secret value.
+- Keep Paperclip-managed AWS values under the Paperclip managed namespace.
+ External refs under that namespace are blocked by guardrails; use the
+ Paperclip-managed flow when Paperclip should create and rotate the value.
+
+### IAM Caveats
+
+Paperclip enforces company scoping, responsible-user derivation, declaration
+policy, current-user value APIs, redaction, and access-event metadata. It does
+not replace the IAM policy of an external vault.
+
+For AWS Secrets Manager, the Paperclip runtime role needs `GetSecretValue` and
+any required KMS decrypt permission for every user-scoped value it may resolve.
+If you link user-specific external refs outside the Paperclip managed prefix,
+scope those permissions to the approved external prefixes and KMS keys. AWS
+tag/name filters help operators search, but they are not a reliable permission
+boundary for resolution.
+
+If stronger provider-side isolation is required, split user-secret workloads by
+provider vault, AWS account, Region, prefix, or runtime role before linking the
+refs. Paperclip can prevent an agent from choosing another credential owner,
+but a runtime role with broad external vault read permissions can still read
+what IAM allows if another code path is introduced outside Paperclip.
+
## Default Provider: `local_encrypted`
Secrets are encrypted with a local master key stored at:
@@ -319,13 +427,18 @@ Each provider family has a different backup story:
- `local_encrypted`: back up the local master key file and the Paperclip
database together. Either alone is not enough to restore the encrypted
values, and the vault row only records the path and acknowledgement, not the
- key bytes.
+ key bytes. This includes user-scoped values: the database holds
+ `user_secret_definitions`, `user_secret_declarations`,
+ `company_secrets.scope = "user"` rows, version metadata, and owner ids; the
+ key file is required to decrypt their local material.
- `aws_secrets_manager`: back up Paperclip's database for vault metadata
(vault id, region, prefix, KMS key id, default flag, bindings, version
- pointers). The actual secret values live in AWS Secrets Manager under the
- configured prefix; restore by pointing the same Paperclip company at the
- same AWS namespace and confirming the runtime role still has
- `GetSecretValue` plus KMS decrypt. The full restore checklist lives in
+ pointers, user-secret definitions/declarations, owner ids, and access-event
+ metadata). The actual secret values live in AWS Secrets Manager under the
+ configured prefix or operator-owned external refs; restore by pointing the
+ same Paperclip company at the same AWS namespace and confirming the runtime
+ role still has `GetSecretValue` plus KMS decrypt for both managed and linked
+ user-scoped values. The full restore checklist lives in
`doc/SECRETS-AWS-PROVIDER.md`.
- `gcp_secret_manager` and `vault`: while these are coming soon, only the
draft vault config exists in Paperclip. Database backups capture it. There
diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts
index 44db5168be..477c0188af 100644
--- a/packages/adapters/codex-local/src/server/codex-home.test.ts
+++ b/packages/adapters/codex-local/src/server/codex-home.test.ts
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
codexHomeHasUsableAuth,
ensureSymlink,
+ evaluateCodexCredentialReadiness,
isManagedCodexHomePath,
prepareManagedCodexHome,
reconcileManagedCodexHome,
@@ -502,3 +503,128 @@ describe("reconcileManagedCodexHome", () => {
}
});
});
+
+describe("evaluateCodexCredentialReadiness", () => {
+ async function makeFixture() {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-readiness-"));
+ const sharedCodexHome = path.join(root, "shared-codex-home");
+ const paperclipHome = path.join(root, "paperclip-home");
+ const companyRoot = path.join(
+ paperclipHome,
+ "instances",
+ "default",
+ "companies",
+ "company-1",
+ );
+ const managedCompanyHome = path.join(companyRoot, "codex-home");
+ const managedAgentHome = path.join(companyRoot, "agents", "agent-1", "codex-home");
+ const env: NodeJS.ProcessEnv = {
+ CODEX_HOME: sharedCodexHome,
+ PAPERCLIP_HOME: paperclipHome,
+ PAPERCLIP_INSTANCE_ID: "default",
+ };
+ await fs.mkdir(sharedCodexHome, { recursive: true });
+ return { root, sharedCodexHome, managedCompanyHome, managedAgentHome, env };
+ }
+
+ async function writeUsableAuth(home: string) {
+ await fs.mkdir(home, { recursive: true });
+ await fs.writeFile(path.join(home, "auth.json"), '{"OPENAI_API_KEY":"sk-live"}\n', "utf8");
+ }
+
+ it("flags a managed home with no source auth and empty OPENAI_API_KEY as not ready", async () => {
+ const fx = await makeFixture();
+ try {
+ const result = await evaluateCodexCredentialReadiness({
+ env: fx.env,
+ companyId: "company-1",
+ configuredCodexHome: fx.managedAgentHome,
+ configuredApiKey: "",
+ });
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: false });
+ expect(result.effectiveHome).toBe(path.resolve(fx.managedAgentHome));
+ } finally {
+ await fs.rm(fx.root, { recursive: true, force: true });
+ }
+ });
+
+ it("treats a non-empty resolved OPENAI_API_KEY as ready without touching disk", async () => {
+ const fx = await makeFixture();
+ try {
+ const result = await evaluateCodexCredentialReadiness({
+ env: fx.env,
+ companyId: "company-1",
+ configuredCodexHome: fx.managedAgentHome,
+ configuredApiKey: "sk-agent-key",
+ });
+ expect(result).toMatchObject({ managed: true, authMode: "api", ready: true });
+ } finally {
+ await fs.rm(fx.root, { recursive: true, force: true });
+ }
+ });
+
+ it("is ready when the shared source home carries usable subscription auth", async () => {
+ const fx = await makeFixture();
+ try {
+ await writeUsableAuth(fx.sharedCodexHome);
+ const result = await evaluateCodexCredentialReadiness({
+ env: fx.env,
+ companyId: "company-1",
+ configuredCodexHome: fx.managedAgentHome,
+ configuredApiKey: "",
+ });
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: true });
+ } finally {
+ await fs.rm(fx.root, { recursive: true, force: true });
+ }
+ });
+
+ it("is ready when the already-seeded effective home carries usable auth", async () => {
+ const fx = await makeFixture();
+ try {
+ await writeUsableAuth(fx.managedAgentHome);
+ const result = await evaluateCodexCredentialReadiness({
+ env: fx.env,
+ companyId: "company-1",
+ configuredCodexHome: fx.managedAgentHome,
+ configuredApiKey: "",
+ });
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: true });
+ } finally {
+ await fs.rm(fx.root, { recursive: true, force: true });
+ }
+ });
+
+ it("defaults to the managed company home when no CODEX_HOME is configured", async () => {
+ const fx = await makeFixture();
+ try {
+ const result = await evaluateCodexCredentialReadiness({
+ env: fx.env,
+ companyId: "company-1",
+ configuredCodexHome: null,
+ configuredApiKey: "",
+ });
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: false });
+ expect(result.effectiveHome).toBe(path.resolve(fx.managedCompanyHome));
+ } finally {
+ await fs.rm(fx.root, { recursive: true, force: true });
+ }
+ });
+
+ it("treats an external/user-supplied CODEX_HOME override as self-managed and ready", async () => {
+ const fx = await makeFixture();
+ try {
+ const externalHome = path.join(fx.root, "user-codex-home");
+ await fs.mkdir(externalHome, { recursive: true });
+ const result = await evaluateCodexCredentialReadiness({
+ env: fx.env,
+ companyId: "company-1",
+ configuredCodexHome: externalHome,
+ configuredApiKey: "",
+ });
+ expect(result).toMatchObject({ managed: false, ready: true });
+ } finally {
+ await fs.rm(fx.root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts
index d20b5fed0f..b8b6e245c0 100644
--- a/packages/adapters/codex-local/src/server/codex-home.ts
+++ b/packages/adapters/codex-local/src/server/codex-home.ts
@@ -346,3 +346,75 @@ export async function reconcileManagedCodexHome(
!apiKey && hadUsableAuth ? "already_seeded" : "seeded";
return { status, home: resolved };
}
+
+export type CodexCredentialAuthMode = "api" | "subscription";
+
+export interface CodexCredentialReadinessInput {
+ env?: NodeJS.ProcessEnv;
+ companyId: string | undefined;
+ /** `config.env.CODEX_HOME` for the run, if any. */
+ configuredCodexHome: string | null | undefined;
+ /** Resolved `config.env.OPENAI_API_KEY` value (after secret resolution). */
+ configuredApiKey: string | null | undefined;
+}
+
+export interface CodexCredentialReadiness {
+ /** True when Paperclip owns the effective home and is responsible for its auth. */
+ managed: boolean;
+ authMode: CodexCredentialAuthMode;
+ /** True when a run launched now would be able to authenticate. */
+ ready: boolean;
+ effectiveHome: string;
+ /** The shared source home subscription auth is symlinked from (managed homes only). */
+ sharedSourceHome: string;
+}
+
+/**
+ * Read-only predictor for whether a `codex_local` run will be able to
+ * authenticate, without seeding or mutating any home. Mirrors the execute-time
+ * fail-fast in `execute.ts`, factored out so the control plane can run the same
+ * check *before* dispatch and surface a configuration-incomplete blocker instead
+ * of dispatching a run that is guaranteed to fail with "no Codex credentials".
+ *
+ * - An external/user-supplied `CODEX_HOME` override manages its own auth, so it
+ * is always treated as ready (Paperclip must not seed or inspect it).
+ * - A non-empty resolved `OPENAI_API_KEY` means API-key auth, always ready.
+ * - Otherwise (subscription mode) the run needs a usable `auth.json`. Because a
+ * managed home symlinks `auth.json` from the shared source home at seed time,
+ * we treat the run as ready when either the (possibly already-seeded) effective
+ * home or the shared source home carries usable auth.
+ */
+export async function evaluateCodexCredentialReadiness(
+ input: CodexCredentialReadinessInput,
+): Promise {
+ const env = input.env ?? process.env;
+ const configuredRaw = nonEmpty(input.configuredCodexHome ?? undefined);
+ const configuredCodexHome = configuredRaw ? path.resolve(configuredRaw) : null;
+ const configuredApiKey = nonEmpty(input.configuredApiKey ?? undefined);
+ const sharedSourceHome = resolveSharedCodexHomeDir(env);
+
+ const configuredHomeIsManaged =
+ configuredCodexHome != null && isManagedCodexHomePath(env, input.companyId, configuredCodexHome);
+ const effectiveHomeIsManaged = configuredCodexHome == null || configuredHomeIsManaged;
+ const effectiveHome = configuredCodexHome ?? resolveManagedCodexHomeDir(env, input.companyId);
+
+ if (!effectiveHomeIsManaged) {
+ // Genuine external override: Paperclip never seeds or inspects it.
+ return {
+ managed: false,
+ authMode: configuredApiKey ? "api" : "subscription",
+ ready: true,
+ effectiveHome,
+ sharedSourceHome,
+ };
+ }
+
+ if (configuredApiKey) {
+ return { managed: true, authMode: "api", ready: true, effectiveHome, sharedSourceHome };
+ }
+
+ const ready =
+ (await codexHomeHasUsableAuth(effectiveHome)) ||
+ (await codexHomeHasUsableAuth(sharedSourceHome));
+ return { managed: true, authMode: "subscription", ready, effectiveHome, sharedSourceHome };
+}
diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts
index ae5e759125..d1f535ce9a 100644
--- a/packages/adapters/codex-local/src/server/execute.ts
+++ b/packages/adapters/codex-local/src/server/execute.ts
@@ -45,7 +45,7 @@ import {
isCodexUnknownSessionError,
} from "./parse.js";
import {
- codexHomeHasUsableAuth,
+ evaluateCodexCredentialReadiness,
isManagedCodexHomePath,
pathExists,
prepareManagedCodexHome,
@@ -402,12 +402,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise statement-breakpoint
+CREATE TABLE IF NOT EXISTS "user_secret_declarations" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "user_secret_definition_id" uuid NOT NULL,
+ "target_type" text NOT NULL,
+ "target_id" text NOT NULL,
+ "config_path" text NOT NULL,
+ "env_key" text NOT NULL,
+ "version_selector" text DEFAULT 'latest' NOT NULL,
+ "required" boolean DEFAULT true NOT NULL,
+ "allow_missing_override" boolean DEFAULT false NOT NULL,
+ "label" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "scope" text DEFAULT 'company' NOT NULL;
+--> statement-breakpoint
+ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "owner_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "company_secrets" ADD COLUMN IF NOT EXISTS "user_secret_definition_id" uuid;
+--> statement-breakpoint
+UPDATE "company_secrets"
+SET "scope" = 'company'
+WHERE "scope" IS NULL;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ALTER COLUMN "secret_id" DROP NOT NULL;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "user_secret_definition_id" uuid;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "secret_scope" text DEFAULT 'company' NOT NULL;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_owner_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_subject_type" text;
+--> statement-breakpoint
+ALTER TABLE "secret_access_events" ADD COLUMN IF NOT EXISTS "credential_subject_id" text;
+--> statement-breakpoint
+ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "routine_revisions" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_company_id_companies_id_fk') THEN
+ ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk') THEN
+ ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk" FOREIGN KEY ("provider_config_id") REFERENCES "public"."company_secret_provider_configs"("id") ON DELETE set null ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_created_by_agent_id_agents_id_fk') THEN
+ ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_definitions_updated_by_agent_id_agents_id_fk') THEN
+ ALTER TABLE "user_secret_definitions" ADD CONSTRAINT "user_secret_definitions_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_declarations_company_id_companies_id_fk') THEN
+ ALTER TABLE "user_secret_declarations" ADD CONSTRAINT "user_secret_declarations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk') THEN
+ ALTER TABLE "user_secret_declarations" ADD CONSTRAINT "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_user_secret_definition_id_user_secret_definitions_id_fk') THEN
+ ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("id") ON DELETE set null ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk') THEN
+ ALTER TABLE "secret_access_events" ADD CONSTRAINT "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk" FOREIGN KEY ("user_secret_definition_id") REFERENCES "public"."user_secret_definitions"("id") ON DELETE set null ON UPDATE no action;
+ END IF;
+END $$;
+--> statement-breakpoint
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secrets_scope_shape_check') THEN
+ ALTER TABLE "company_secrets" ADD CONSTRAINT "company_secrets_scope_shape_check" CHECK (
+ ("scope" = 'company' AND "owner_user_id" IS NULL AND "user_secret_definition_id" IS NULL)
+ OR
+ ("scope" = 'user' AND "owner_user_id" IS NOT NULL AND "user_secret_definition_id" IS NOT NULL)
+ );
+ END IF;
+END $$;
+--> statement-breakpoint
+DROP INDEX IF EXISTS "company_secrets_company_name_uq";
+--> statement-breakpoint
+DROP INDEX IF EXISTS "company_secrets_company_key_uq";
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_definitions_company_status_idx" ON "user_secret_definitions" USING btree ("company_id","status");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_definitions_company_provider_idx" ON "user_secret_definitions" USING btree ("company_id","provider");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_definitions_provider_config_idx" ON "user_secret_definitions" USING btree ("provider_config_id");
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "user_secret_definitions_company_key_uq" ON "user_secret_definitions" USING btree ("company_id","key") WHERE "user_secret_definitions"."deleted_at" IS NULL;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_declarations_company_idx" ON "user_secret_declarations" USING btree ("company_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_declarations_definition_idx" ON "user_secret_declarations" USING btree ("user_secret_definition_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_declarations_target_idx" ON "user_secret_declarations" USING btree ("company_id","target_type","target_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_declarations_company_required_idx" ON "user_secret_declarations" USING btree ("company_id","required");
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "user_secret_declarations_target_path_uq" ON "user_secret_declarations" USING btree ("company_id","target_type","target_id","config_path");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "user_secret_declarations_required_override_idx" ON "user_secret_declarations" USING btree ("company_id","allow_missing_override") WHERE "user_secret_declarations"."allow_missing_override" = true;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "company_secrets_company_scope_idx" ON "company_secrets" USING btree ("company_id","scope");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "company_secrets_company_owner_idx" ON "company_secrets" USING btree ("company_id","owner_user_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "company_secrets_user_definition_owner_idx" ON "company_secrets" USING btree ("company_id","user_secret_definition_id","owner_user_id");
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_name_uq" ON "company_secrets" USING btree ("company_id","name") WHERE "company_secrets"."scope" = 'company' AND "company_secrets"."deleted_at" IS NULL;
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_company_key_uq" ON "company_secrets" USING btree ("company_id","key") WHERE "company_secrets"."scope" = 'company' AND "company_secrets"."deleted_at" IS NULL;
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "company_secrets_user_definition_owner_uq" ON "company_secrets" USING btree ("company_id","user_secret_definition_id","owner_user_id") WHERE "company_secrets"."scope" = 'user' AND "company_secrets"."deleted_at" IS NULL;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "secret_access_events_user_definition_created_idx" ON "secret_access_events" USING btree ("user_secret_definition_id","created_at");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "secret_access_events_company_credential_owner_idx" ON "secret_access_events" USING btree ("company_id","credential_owner_user_id","created_at");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "issues_company_responsible_user_idx" ON "issues" USING btree ("company_id","responsible_user_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_responsible_user_idx" ON "heartbeat_runs" USING btree ("company_id","responsible_user_id","created_at");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "routines_company_responsible_user_idx" ON "routines" USING btree ("company_id","responsible_user_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "routine_revisions_company_responsible_user_idx" ON "routine_revisions" USING btree ("company_id","responsible_user_id","created_at");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "routine_runs_company_responsible_user_idx" ON "routine_runs" USING btree ("company_id","responsible_user_id","created_at");
diff --git a/packages/db/src/migrations/0129_agent_api_key_responsible_user.sql b/packages/db/src/migrations/0129_agent_api_key_responsible_user.sql
new file mode 100644
index 0000000000..e1a2a25352
--- /dev/null
+++ b/packages/db/src/migrations/0129_agent_api_key_responsible_user.sql
@@ -0,0 +1,41 @@
+ALTER TABLE "agent_api_keys" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
+--> statement-breakpoint
+UPDATE "agent_api_keys" AS key
+SET "responsible_user_id" = created_by."responsible_user_id"
+FROM (
+ SELECT DISTINCT ON (key_id)
+ key_id,
+ responsible_user_id
+ FROM (
+ SELECT
+ log.details ->> 'keyId' AS key_id,
+ log.actor_id AS responsible_user_id,
+ log.created_at
+ FROM "activity_log" AS log
+ WHERE log.action = 'agent.key_created'
+ AND log.actor_type = 'user'
+ AND log.details ->> 'keyId' IS NOT NULL
+ AND log.actor_id IS NOT NULL
+ AND log.actor_id <> ''
+
+ UNION ALL
+
+ SELECT
+ log.entity_id AS key_id,
+ request.approved_by_user_id AS responsible_user_id,
+ log.created_at
+ FROM "activity_log" AS log
+ INNER JOIN "join_requests" AS request
+ ON request.id::text = log.details ->> 'joinRequestId'
+ WHERE log.action = 'agent_api_key.claimed'
+ AND log.entity_type = 'agent_api_key'
+ AND log.entity_id IS NOT NULL
+ AND request.approved_by_user_id IS NOT NULL
+ AND request.approved_by_user_id <> ''
+ ) AS candidates
+ WHERE responsible_user_id IS NOT NULL
+ AND responsible_user_id <> ''
+ ORDER BY key_id, created_at ASC
+) AS created_by
+WHERE key.id::text = created_by.key_id
+ AND key.responsible_user_id IS NULL;
diff --git a/packages/db/src/migrations/0130_run_responsible_user_invariant.sql b/packages/db/src/migrations/0130_run_responsible_user_invariant.sql
new file mode 100644
index 0000000000..87bc74d771
--- /dev/null
+++ b/packages/db/src/migrations/0130_run_responsible_user_invariant.sql
@@ -0,0 +1,165 @@
+ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "default_responsible_user_id" text;
+--> statement-breakpoint
+WITH owner_defaults AS (
+ SELECT DISTINCT ON ("company_id")
+ "company_id",
+ "principal_id" AS "user_id"
+ FROM "company_memberships"
+ WHERE "principal_type" = 'user'
+ AND "status" = 'active'
+ AND "membership_role" = 'owner'
+ ORDER BY "company_id", "created_at" ASC, "id" ASC
+)
+UPDATE "companies" AS c
+SET "default_responsible_user_id" = owner_defaults."user_id",
+ "updated_at" = now()
+FROM owner_defaults
+WHERE c."id" = owner_defaults."company_id"
+ AND c."default_responsible_user_id" IS NULL;
+--> statement-breakpoint
+WITH RECURSIVE issue_chain AS (
+ SELECT
+ child."id" AS "issue_id",
+ child."company_id",
+ child."parent_id",
+ child."responsible_user_id",
+ child."created_by_user_id",
+ 0 AS "depth"
+ FROM "issues" AS child
+ WHERE child."responsible_user_id" IS NULL
+ UNION ALL
+ SELECT
+ issue_chain."issue_id",
+ parent."company_id",
+ parent."parent_id",
+ parent."responsible_user_id",
+ parent."created_by_user_id",
+ issue_chain."depth" + 1
+ FROM issue_chain
+ JOIN "issues" AS parent
+ ON parent."id" = issue_chain."parent_id"
+ AND parent."company_id" = issue_chain."company_id"
+ WHERE issue_chain."depth" < 50
+),
+resolved_issue_users AS (
+ SELECT DISTINCT ON ("issue_id")
+ "issue_id",
+ COALESCE("responsible_user_id", "created_by_user_id") AS "user_id"
+ FROM issue_chain
+ WHERE COALESCE("responsible_user_id", "created_by_user_id") IS NOT NULL
+ ORDER BY "issue_id", "depth" ASC
+)
+UPDATE "issues" AS i
+SET "responsible_user_id" = resolved_issue_users."user_id",
+ "updated_at" = now()
+FROM resolved_issue_users
+WHERE i."id" = resolved_issue_users."issue_id"
+ AND i."responsible_user_id" IS NULL;
+--> statement-breakpoint
+UPDATE "issues" AS i
+SET "responsible_user_id" = c."default_responsible_user_id",
+ "updated_at" = now()
+FROM "companies" AS c
+WHERE i."company_id" = c."id"
+ AND i."responsible_user_id" IS NULL
+ AND c."default_responsible_user_id" IS NOT NULL;
+--> statement-breakpoint
+WITH routine_responsible_users AS (
+ SELECT
+ r."id",
+ COALESCE(r."created_by_user_id", parent_issue."responsible_user_id", c."default_responsible_user_id") AS "user_id"
+ FROM "routines" AS r
+ JOIN "companies" AS c ON c."id" = r."company_id"
+ LEFT JOIN "issues" AS parent_issue
+ ON parent_issue."id" = r."parent_issue_id"
+ AND parent_issue."company_id" = r."company_id"
+ WHERE r."responsible_user_id" IS NULL
+)
+UPDATE "routines" AS r
+SET "responsible_user_id" = routine_responsible_users."user_id",
+ "updated_at" = now()
+FROM routine_responsible_users
+WHERE r."id" = routine_responsible_users."id"
+ AND routine_responsible_users."user_id" IS NOT NULL;
+--> statement-breakpoint
+WITH routine_revision_responsible_users AS (
+ SELECT
+ rr."id",
+ COALESCE(rr."created_by_user_id", r."responsible_user_id", c."default_responsible_user_id") AS "user_id"
+ FROM "routine_revisions" AS rr
+ JOIN "routines" AS r
+ ON rr."routine_id" = r."id"
+ AND rr."company_id" = r."company_id"
+ JOIN "companies" AS c ON c."id" = rr."company_id"
+ WHERE rr."responsible_user_id" IS NULL
+)
+UPDATE "routine_revisions" AS rr
+SET "responsible_user_id" = routine_revision_responsible_users."user_id"
+FROM routine_revision_responsible_users
+WHERE rr."id" = routine_revision_responsible_users."id"
+ AND routine_revision_responsible_users."user_id" IS NOT NULL;
+--> statement-breakpoint
+WITH routine_run_responsible_users AS (
+ SELECT
+ rr."id",
+ COALESCE(linked_issue."responsible_user_id", r."responsible_user_id", c."default_responsible_user_id") AS "user_id"
+ FROM "routine_runs" AS rr
+ JOIN "routines" AS r
+ ON rr."routine_id" = r."id"
+ AND rr."company_id" = r."company_id"
+ JOIN "companies" AS c ON c."id" = rr."company_id"
+ LEFT JOIN "issues" AS linked_issue
+ ON linked_issue."id" = rr."linked_issue_id"
+ AND linked_issue."company_id" = rr."company_id"
+ WHERE rr."responsible_user_id" IS NULL
+)
+UPDATE "routine_runs" AS rr
+SET "responsible_user_id" = routine_run_responsible_users."user_id",
+ "updated_at" = now()
+FROM routine_run_responsible_users
+WHERE rr."id" = routine_run_responsible_users."id"
+ AND routine_run_responsible_users."user_id" IS NOT NULL;
+--> statement-breakpoint
+UPDATE "heartbeat_runs" AS h
+SET "responsible_user_id" = original."responsible_user_id",
+ "updated_at" = now()
+FROM "heartbeat_runs" AS original
+WHERE h."retry_of_run_id" = original."id"
+ AND h."company_id" = original."company_id"
+ AND h."responsible_user_id" IS NULL
+ AND original."responsible_user_id" IS NOT NULL;
+--> statement-breakpoint
+UPDATE "heartbeat_runs" AS h
+SET "responsible_user_id" = i."responsible_user_id",
+ "updated_at" = now()
+FROM "issues" AS i
+WHERE h."company_id" = i."company_id"
+ AND h."responsible_user_id" IS NULL
+ AND i."responsible_user_id" IS NOT NULL
+ AND (
+ h."context_snapshot" ->> 'issueId' = i."id"::text
+ OR h."context_snapshot" ->> 'taskId' = i."id"::text
+ OR h."context_snapshot" ->> 'issueId' = i."identifier"
+ OR h."context_snapshot" ->> 'taskId' = i."identifier"
+ );
+--> statement-breakpoint
+UPDATE "heartbeat_runs" AS h
+SET "responsible_user_id" = awr."requested_by_actor_id",
+ "updated_at" = now()
+FROM "agent_wakeup_requests" AS awr
+WHERE h."wakeup_request_id" = awr."id"
+ AND h."company_id" = awr."company_id"
+ AND h."responsible_user_id" IS NULL
+ AND awr."requested_by_actor_type" = 'user'
+ AND awr."requested_by_actor_id" IS NOT NULL;
+--> statement-breakpoint
+UPDATE "heartbeat_runs" AS h
+SET "responsible_user_id" = c."default_responsible_user_id",
+ "updated_at" = now()
+FROM "companies" AS c
+WHERE h."company_id" = c."id"
+ AND h."responsible_user_id" IS NULL
+ AND c."default_responsible_user_id" IS NOT NULL;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "companies_default_responsible_user_idx"
+ ON "companies" ("default_responsible_user_id");
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 9cac3b4213..e3e0473eb2 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -897,6 +897,27 @@
"when": 1782526500000,
"tag": "0127_environment_custom_images_instance_scoped",
"breakpoints": true
+ },
+ {
+ "idx": 128,
+ "version": "7",
+ "when": 1782923938661,
+ "tag": "0128_user_specific_secrets",
+ "breakpoints": true
+ },
+ {
+ "idx": 129,
+ "version": "7",
+ "when": 1783025124120,
+ "tag": "0129_agent_api_key_responsible_user",
+ "breakpoints": true
+ },
+ {
+ "idx": 130,
+ "version": "7",
+ "when": 1783025224120,
+ "tag": "0130_run_responsible_user_invariant",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/schema/agent_api_keys.ts b/packages/db/src/schema/agent_api_keys.ts
index 8cc430ae00..7966f55d9c 100644
--- a/packages/db/src/schema/agent_api_keys.ts
+++ b/packages/db/src/schema/agent_api_keys.ts
@@ -11,6 +11,7 @@ export const agentApiKeys = pgTable(
companyId: uuid("company_id").notNull().references(() => companies.id),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
+ responsibleUserId: text("responsible_user_id"),
scopeConfig: jsonb("scope_config").$type(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
diff --git a/packages/db/src/schema/companies.ts b/packages/db/src/schema/companies.ts
index d66b330173..c4c9c3ce97 100644
--- a/packages/db/src/schema/companies.ts
+++ b/packages/db/src/schema/companies.ts
@@ -16,6 +16,7 @@ export const companies = pgTable(
attachmentMaxBytes: integer("attachment_max_bytes")
.notNull()
.default(10 * 1024 * 1024),
+ defaultResponsibleUserId: text("default_responsible_user_id"),
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
.notNull()
.default(false),
diff --git a/packages/db/src/schema/company_secrets.ts b/packages/db/src/schema/company_secrets.ts
index 9499d20cc8..9ca340d224 100644
--- a/packages/db/src/schema/company_secrets.ts
+++ b/packages/db/src/schema/company_secrets.ts
@@ -1,13 +1,18 @@
-import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
+import { sql } from "drizzle-orm";
+import { check, pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
+import { userSecretDefinitions } from "./user_secret_definitions.js";
export const companySecrets = pgTable(
"company_secrets",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
+ scope: text("scope").notNull().default("company"),
+ ownerUserId: text("owner_user_id"),
+ userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }),
key: text("key").notNull(),
name: text("name").notNull(),
provider: text("provider").notNull().default("local_encrypted"),
@@ -28,9 +33,35 @@ export const companySecrets = pgTable(
},
(table) => ({
companyIdx: index("company_secrets_company_idx").on(table.companyId),
+ companyScopeIdx: index("company_secrets_company_scope_idx").on(table.companyId, table.scope),
+ companyOwnerIdx: index("company_secrets_company_owner_idx").on(table.companyId, table.ownerUserId),
+ userDefinitionOwnerIdx: index("company_secrets_user_definition_owner_idx").on(
+ table.companyId,
+ table.userSecretDefinitionId,
+ table.ownerUserId,
+ ),
companyProviderIdx: index("company_secrets_company_provider_idx").on(table.companyId, table.provider),
providerConfigIdx: index("company_secrets_provider_config_idx").on(table.providerConfigId),
- companyNameUq: uniqueIndex("company_secrets_company_name_uq").on(table.companyId, table.name),
- companyKeyUq: uniqueIndex("company_secrets_company_key_uq").on(table.companyId, table.key),
+ companyNameUq: uniqueIndex("company_secrets_company_name_uq")
+ .on(table.companyId, table.name)
+ .where(sql`${table.scope} = 'company' and ${table.deletedAt} is null`),
+ companyKeyUq: uniqueIndex("company_secrets_company_key_uq")
+ .on(table.companyId, table.key)
+ .where(sql`${table.scope} = 'company' and ${table.deletedAt} is null`),
+ userDefinitionOwnerUq: uniqueIndex("company_secrets_user_definition_owner_uq")
+ .on(table.companyId, table.userSecretDefinitionId, table.ownerUserId)
+ .where(sql`${table.scope} = 'user' and ${table.deletedAt} is null`),
+ scopeShapeCheck: check(
+ "company_secrets_scope_shape_check",
+ sql`(
+ ${table.scope} = 'company'
+ and ${table.ownerUserId} is null
+ and ${table.userSecretDefinitionId} is null
+ ) or (
+ ${table.scope} = 'user'
+ and ${table.ownerUserId} is not null
+ and ${table.userSecretDefinitionId} is not null
+ )`,
+ ),
}),
);
diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts
index c975892a1e..3d0e39f671 100644
--- a/packages/db/src/schema/heartbeat_runs.ts
+++ b/packages/db/src/schema/heartbeat_runs.ts
@@ -12,6 +12,7 @@ export const heartbeatRuns = pgTable(
invocationSource: text("invocation_source").notNull().default("on_demand"),
triggerDetail: text("trigger_detail"),
status: text("status").notNull().default("queued"),
+ responsibleUserId: text("responsible_user_id"),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
error: text("error"),
@@ -63,6 +64,11 @@ export const heartbeatRuns = pgTable(
table.agentId,
table.startedAt,
),
+ companyResponsibleUserIdx: index("heartbeat_runs_company_responsible_user_idx").on(
+ table.companyId,
+ table.responsibleUserId,
+ table.createdAt,
+ ),
companyLivenessIdx: index("heartbeat_runs_company_liveness_idx").on(
table.companyId,
table.livenessState,
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index 314b783441..6d418922cc 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -84,9 +84,11 @@ export { approvals } from "./approvals.js";
export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";
export { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
+export { userSecretDefinitions } from "./user_secret_definitions.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { companySecretBindings } from "./company_secret_bindings.js";
+export { userSecretDeclarations } from "./user_secret_declarations.js";
export { secretAccessEvents } from "./secret_access_events.js";
export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js";
export { plugins } from "./plugins.js";
diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts
index 5c945b5a5b..18164184f4 100644
--- a/packages/db/src/schema/issues.ts
+++ b/packages/db/src/schema/issues.ts
@@ -41,6 +41,7 @@ export const issues = pgTable(
executionLockedAt: timestamp("execution_locked_at", { withTimezone: true }),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
createdByUserId: text("created_by_user_id"),
+ responsibleUserId: text("responsible_user_id"),
issueNumber: integer("issue_number"),
identifier: text("identifier"),
originKind: text("origin_kind").notNull().default("manual"),
@@ -82,6 +83,7 @@ export const issues = pgTable(
table.assigneeUserId,
table.status,
),
+ responsibleUserIdx: index("issues_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
parentIdx: index("issues_company_parent_idx").on(table.companyId, table.parentId),
projectIdx: index("issues_company_project_idx").on(table.companyId, table.projectId),
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts
index 08f7abcce6..3142d887ac 100644
--- a/packages/db/src/schema/routines.ts
+++ b/packages/db/src/schema/routines.ts
@@ -42,6 +42,7 @@ export const routines = pgTable(
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
+ responsibleUserId: text("responsible_user_id"),
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
updatedByUserId: text("updated_by_user_id"),
lastTriggeredAt: timestamp("last_triggered_at", { withTimezone: true }),
@@ -53,6 +54,7 @@ export const routines = pgTable(
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
+ companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId),
}),
);
@@ -75,6 +77,7 @@ export const routineRevisions = pgTable(
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
+ responsibleUserId: text("responsible_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
@@ -87,6 +90,11 @@ export const routineRevisions = pgTable(
table.routineId,
table.createdAt,
),
+ companyResponsibleUserIdx: index("routine_revisions_company_responsible_user_idx").on(
+ table.companyId,
+ table.responsibleUserId,
+ table.createdAt,
+ ),
}),
);
@@ -136,6 +144,7 @@ export const routineRuns = pgTable(
status: text("status").notNull().default("received"),
triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(),
routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }),
+ responsibleUserId: text("responsible_user_id"),
idempotencyKey: text("idempotency_key"),
triggerPayload: jsonb("trigger_payload").$type>(),
dispatchFingerprint: text("dispatch_fingerprint"),
@@ -149,6 +158,11 @@ export const routineRuns = pgTable(
(table) => ({
companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt),
routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId),
+ companyResponsibleUserIdx: index("routine_runs_company_responsible_user_idx").on(
+ table.companyId,
+ table.responsibleUserId,
+ table.createdAt,
+ ),
triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt),
dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint),
linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId),
diff --git a/packages/db/src/schema/secret_access_events.ts b/packages/db/src/schema/secret_access_events.ts
index b4967f13fd..15a34720e4 100644
--- a/packages/db/src/schema/secret_access_events.ts
+++ b/packages/db/src/schema/secret_access_events.ts
@@ -4,15 +4,22 @@ import { companySecrets } from "./company_secrets.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
import { plugins } from "./plugins.js";
+import { userSecretDefinitions } from "./user_secret_definitions.js";
export const secretAccessEvents = pgTable(
"secret_access_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
- secretId: uuid("secret_id").notNull().references(() => companySecrets.id, { onDelete: "cascade" }),
+ secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "cascade" }),
+ userSecretDefinitionId: uuid("user_secret_definition_id").references(() => userSecretDefinitions.id, { onDelete: "set null" }),
+ secretScope: text("secret_scope").notNull().default("company"),
version: integer("version"),
provider: text("provider").notNull(),
+ responsibleUserId: text("responsible_user_id"),
+ credentialOwnerUserId: text("credential_owner_user_id"),
+ credentialSubjectType: text("credential_subject_type"),
+ credentialSubjectId: text("credential_subject_id"),
actorType: text("actor_type").notNull(),
actorId: text("actor_id"),
consumerType: text("consumer_type").notNull(),
@@ -28,6 +35,15 @@ export const secretAccessEvents = pgTable(
(table) => ({
companyCreatedIdx: index("secret_access_events_company_created_idx").on(table.companyId, table.createdAt),
secretCreatedIdx: index("secret_access_events_secret_created_idx").on(table.secretId, table.createdAt),
+ userDefinitionCreatedIdx: index("secret_access_events_user_definition_created_idx").on(
+ table.userSecretDefinitionId,
+ table.createdAt,
+ ),
+ credentialOwnerIdx: index("secret_access_events_company_credential_owner_idx").on(
+ table.companyId,
+ table.credentialOwnerUserId,
+ table.createdAt,
+ ),
consumerIdx: index("secret_access_events_consumer_idx").on(table.companyId, table.consumerType, table.consumerId),
runIdx: index("secret_access_events_run_idx").on(table.heartbeatRunId),
}),
diff --git a/packages/db/src/schema/user_secret_declarations.ts b/packages/db/src/schema/user_secret_declarations.ts
new file mode 100644
index 0000000000..5dc67c0f72
--- /dev/null
+++ b/packages/db/src/schema/user_secret_declarations.ts
@@ -0,0 +1,40 @@
+import { sql } from "drizzle-orm";
+import { boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
+import { companies } from "./companies.js";
+import { userSecretDefinitions } from "./user_secret_definitions.js";
+
+export const userSecretDeclarations = pgTable(
+ "user_secret_declarations",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
+ userSecretDefinitionId: uuid("user_secret_definition_id")
+ .notNull()
+ .references(() => userSecretDefinitions.id, { onDelete: "cascade" }),
+ targetType: text("target_type").notNull(),
+ targetId: text("target_id").notNull(),
+ configPath: text("config_path").notNull(),
+ envKey: text("env_key").notNull(),
+ versionSelector: text("version_selector").notNull().default("latest"),
+ required: boolean("required").notNull().default(true),
+ allowMissingOverride: boolean("allow_missing_override").notNull().default(false),
+ label: text("label"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ companyIdx: index("user_secret_declarations_company_idx").on(table.companyId),
+ definitionIdx: index("user_secret_declarations_definition_idx").on(table.userSecretDefinitionId),
+ targetIdx: index("user_secret_declarations_target_idx").on(table.companyId, table.targetType, table.targetId),
+ companyRequiredIdx: index("user_secret_declarations_company_required_idx").on(table.companyId, table.required),
+ targetPathUq: uniqueIndex("user_secret_declarations_target_path_uq").on(
+ table.companyId,
+ table.targetType,
+ table.targetId,
+ table.configPath,
+ ),
+ requiredOverrideCheck: index("user_secret_declarations_required_override_idx")
+ .on(table.companyId, table.allowMissingOverride)
+ .where(sql`${table.allowMissingOverride} = true`),
+ }),
+);
diff --git a/packages/db/src/schema/user_secret_definitions.ts b/packages/db/src/schema/user_secret_definitions.ts
new file mode 100644
index 0000000000..0bc64e3fb5
--- /dev/null
+++ b/packages/db/src/schema/user_secret_definitions.ts
@@ -0,0 +1,37 @@
+import { sql } from "drizzle-orm";
+import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
+import { agents } from "./agents.js";
+import { companies } from "./companies.js";
+import { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
+
+export const userSecretDefinitions = pgTable(
+ "user_secret_definitions",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
+ key: text("key").notNull(),
+ name: text("name").notNull(),
+ description: text("description"),
+ status: text("status").notNull().default("active"),
+ provider: text("provider").notNull().default("local_encrypted"),
+ managedMode: text("managed_mode").notNull().default("paperclip_managed"),
+ providerConfigId: uuid("provider_config_id").references(() => companySecretProviderConfigs.id, { onDelete: "set null" }),
+ providerMetadata: jsonb("provider_metadata").$type>(),
+ usageGuidance: text("usage_guidance"),
+ createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
+ createdByUserId: text("created_by_user_id"),
+ updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
+ updatedByUserId: text("updated_by_user_id"),
+ deletedAt: timestamp("deleted_at", { withTimezone: true }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ companyStatusIdx: index("user_secret_definitions_company_status_idx").on(table.companyId, table.status),
+ companyProviderIdx: index("user_secret_definitions_company_provider_idx").on(table.companyId, table.provider),
+ providerConfigIdx: index("user_secret_definitions_provider_config_idx").on(table.providerConfigId),
+ companyKeyUq: uniqueIndex("user_secret_definitions_company_key_uq")
+ .on(table.companyId, table.key)
+ .where(sql`${table.deletedAt} is null`),
+ }),
+);
diff --git a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts
index 18c05ee82c..37eea47747 100644
--- a/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts
+++ b/packages/plugins/plugin-llm-wiki/tests/plugin.spec.ts
@@ -573,6 +573,7 @@ function paperclipIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts
index 769c3471a6..c53b930364 100644
--- a/packages/plugins/sdk/src/testing.ts
+++ b/packages/plugins/sdk/src/testing.ts
@@ -1206,6 +1206,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
parentIssueId: null,
title: declaration.title,
description: declaration.description ?? null,
+ responsibleUserId: null,
assigneeAgentId,
priority: declaration.priority ?? "medium",
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
@@ -1574,6 +1575,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
+ responsibleUserId: null,
issueNumber: null,
identifier: null,
originKind,
diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts
index e387809700..f4c0d29ddc 100644
--- a/packages/shared/src/api.ts
+++ b/packages/shared/src/api.ts
@@ -22,6 +22,11 @@ export const API = {
goals: `${API_PREFIX}/goals`,
approvals: `${API_PREFIX}/approvals`,
secrets: `${API_PREFIX}/secrets`,
+ userSecretDefinitions: `${API_PREFIX}/companies/:companyId/user-secret-definitions`,
+ userSecretDefinition: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId`,
+ userSecretDefinitionCoverage: `${API_PREFIX}/companies/:companyId/user-secret-definitions/:definitionId/coverage`,
+ myUserSecrets: `${API_PREFIX}/companies/:companyId/me/user-secrets`,
+ myUserSecret: `${API_PREFIX}/companies/:companyId/me/user-secrets/:secretId`,
secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`,
secretProviderConfigDiscoveryPreview: `${API_PREFIX}/companies/:companyId/secret-provider-configs/discovery/preview`,
costs: `${API_PREFIX}/costs`,
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 8e0461ecdd..6d124a9531 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -618,6 +618,9 @@ export type SecretProviderConfigHealthStatus =
export const SECRET_STATUSES = ["active", "disabled", "archived", "deleted"] as const;
export type SecretStatus = (typeof SECRET_STATUSES)[number];
+export const SECRET_SCOPES = ["company", "user"] as const;
+export type SecretScope = (typeof SECRET_SCOPES)[number];
+
export const SECRET_MANAGED_MODES = ["paperclip_managed", "external_reference"] as const;
export type SecretManagedMode = (typeof SECRET_MANAGED_MODES)[number];
@@ -642,7 +645,15 @@ export const SECRET_BINDING_TARGET_TYPES = [
] as const;
export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[number];
-export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
+export const SECRET_ACCESS_OUTCOMES = [
+ "success",
+ "failure",
+ "missing",
+ "inactive",
+ "not_allowed",
+ "optional_omitted",
+ "provider_error",
+] as const;
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 376ef01383..d561a47fc0 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -33,6 +33,22 @@ export {
deriveCaseType,
type CaseTypePipelineRef,
} from "./pipeline-case-type.js";
+export {
+ deriveResponsibleUser,
+ deriveOriginatingActor,
+ type ResponsibleUserAttribution,
+ type ResponsibleUserSource,
+ type OriginatingActor,
+} from "./issue-attribution.js";
+export {
+ RESPONSIBLE_USER_DENIAL_CODES,
+ describeResponsibleUserDenial,
+ isResponsibleUserDenialCode,
+ responsibleUserLabel,
+ type ResponsibleUserDenialCode,
+ type ResponsibleUserDenialCopy,
+ type ResponsibleUserDenialTone,
+} from "./responsible-user-denial.js";
export type {
PipelineAutomationRetryBlocker,
PipelineAutomationRetryCleanupOptions,
@@ -183,6 +199,7 @@ export {
SECRET_PROVIDERS,
SECRET_PROVIDER_CONFIG_STATUSES,
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
+ SECRET_SCOPES,
STORAGE_PROVIDERS,
BILLING_TYPES,
FINANCE_EVENT_KINDS,
@@ -320,6 +337,7 @@ export {
type SecretProvider,
type SecretProviderConfigStatus,
type SecretProviderConfigHealthStatus,
+ type SecretScope,
type StorageProvider,
type BillingType,
type FinanceEventKind,
@@ -820,8 +838,12 @@ export type {
EnvBinding,
EnvPlainBinding,
EnvSecretRefBinding,
+ EnvUserSecretRefBinding,
AgentEnvConfig,
CompanySecret,
+ UserSecretDefinition,
+ UserSecretDeclaration,
+ UserSecretCoverageSummary,
CompanySecretProviderConfig,
SecretProviderConfigPayload,
SecretProviderConfigHealthDetails,
@@ -1288,9 +1310,16 @@ export {
type AddApprovalComment,
envBindingPlainSchema,
envBindingSecretRefSchema,
+ envBindingUserSecretRefSchema,
envBindingSchema,
envConfigSchema,
createSecretSchema,
+ createUserSecretDefinitionSchema,
+ updateUserSecretDefinitionSchema,
+ createUserSecretValueSchema,
+ updateUserSecretValueSchema,
+ rotateUserSecretValueSchema,
+ createUserSecretDeclarationSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
secretProviderConfigDiscoveryPreviewSchema,
diff --git a/packages/shared/src/issue-attribution.test.ts b/packages/shared/src/issue-attribution.test.ts
new file mode 100644
index 0000000000..84563685e7
--- /dev/null
+++ b/packages/shared/src/issue-attribution.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import { deriveOriginatingActor, deriveResponsibleUser } from "./issue-attribution.js";
+
+describe("deriveResponsibleUser", () => {
+ it("prefers an explicit responsible user", () => {
+ expect(
+ deriveResponsibleUser({
+ responsibleUserId: "user-responsible",
+ createdByUserId: "user-creator",
+ }),
+ ).toEqual({
+ userId: "user-responsible",
+ source: "explicit",
+ isAutoDerived: false,
+ });
+ });
+
+ it("falls back to the creator user as an auto-derived responsible user", () => {
+ expect(
+ deriveResponsibleUser({
+ responsibleUserId: null,
+ createdByUserId: "user-creator",
+ }),
+ ).toEqual({
+ userId: "user-creator",
+ source: "creator",
+ isAutoDerived: true,
+ });
+ });
+
+ it("returns none when no human is available", () => {
+ expect(
+ deriveResponsibleUser({
+ responsibleUserId: null,
+ createdByUserId: null,
+ }),
+ ).toEqual({
+ userId: null,
+ source: "none",
+ isAutoDerived: false,
+ });
+ });
+});
+
+describe("deriveOriginatingActor", () => {
+ it("prefers the human creator over an explicit responsible user", () => {
+ expect(
+ deriveOriginatingActor({
+ createdByUserId: "user-creator",
+ createdByAgentId: null,
+ responsibleUserId: "user-responsible",
+ }),
+ ).toEqual({ kind: "user", id: "user-creator" });
+ });
+
+ it("attributes an agent-created issue to the transitive responsible user via the agent", () => {
+ expect(
+ deriveOriginatingActor({
+ createdByUserId: null,
+ createdByAgentId: "agent-claude",
+ responsibleUserId: "user-responsible",
+ }),
+ ).toEqual({ kind: "user", id: "user-responsible", viaAgentId: "agent-claude" });
+ });
+
+ it("falls back to the creating agent when no responsible user is known", () => {
+ expect(
+ deriveOriginatingActor({
+ createdByUserId: null,
+ createdByAgentId: "agent-claude",
+ responsibleUserId: null,
+ }),
+ ).toEqual({ kind: "agent", id: "agent-claude" });
+ });
+
+ it("surfaces the responsible user for routine executions with no creator", () => {
+ expect(
+ deriveOriginatingActor({
+ createdByUserId: null,
+ createdByAgentId: null,
+ responsibleUserId: "user-responsible",
+ }),
+ ).toEqual({ kind: "user", id: "user-responsible" });
+ });
+
+ it("returns null when nothing is attributable", () => {
+ expect(
+ deriveOriginatingActor({
+ createdByUserId: null,
+ createdByAgentId: null,
+ responsibleUserId: null,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/packages/shared/src/issue-attribution.ts b/packages/shared/src/issue-attribution.ts
new file mode 100644
index 0000000000..9c85194fb8
--- /dev/null
+++ b/packages/shared/src/issue-attribution.ts
@@ -0,0 +1,57 @@
+import type { Issue } from "./types/issue.js";
+
+export type ResponsibleUserSource = "explicit" | "creator" | "none";
+
+export interface ResponsibleUserAttribution {
+ userId: string | null;
+ source: ResponsibleUserSource;
+ isAutoDerived: boolean;
+}
+
+export function deriveResponsibleUser(
+ issue: Pick,
+): ResponsibleUserAttribution {
+ if (issue.responsibleUserId) {
+ return { userId: issue.responsibleUserId, source: "explicit", isAutoDerived: false };
+ }
+
+ if (issue.createdByUserId) {
+ return { userId: issue.createdByUserId, source: "creator", isAutoDerived: true };
+ }
+
+ return { userId: null, source: "none", isAutoDerived: false };
+}
+
+/**
+ * The actor to display as an issue's "Originating" attribution.
+ *
+ * A human creator always wins (`createdByUserId`). When an agent created the
+ * issue but a transitive human responsible user is known, we attribute the
+ * originator to that human and record the creating agent as `viaAgentId` so the
+ * UI can show a "via " affordance. Agent-only creators fall back to the
+ * agent, and routine executions (no `createdBy*`) surface the responsible user.
+ */
+export type OriginatingActor =
+ | { kind: "user"; id: string; viaAgentId?: string }
+ | { kind: "agent"; id: string };
+
+export function deriveOriginatingActor(
+ issue: Pick,
+): OriginatingActor | null {
+ if (issue.createdByUserId) {
+ return { kind: "user", id: issue.createdByUserId };
+ }
+
+ if (issue.createdByAgentId) {
+ if (issue.responsibleUserId) {
+ return { kind: "user", id: issue.responsibleUserId, viaAgentId: issue.createdByAgentId };
+ }
+ return { kind: "agent", id: issue.createdByAgentId };
+ }
+
+ if (issue.responsibleUserId) {
+ return { kind: "user", id: issue.responsibleUserId };
+ }
+
+ return null;
+}
diff --git a/packages/shared/src/responsible-user-denial.test.ts b/packages/shared/src/responsible-user-denial.test.ts
new file mode 100644
index 0000000000..bbc4faf914
--- /dev/null
+++ b/packages/shared/src/responsible-user-denial.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import {
+ RESPONSIBLE_USER_DENIAL_CODES,
+ describeResponsibleUserDenial,
+ isResponsibleUserDenialCode,
+ responsibleUserLabel,
+} from "./responsible-user-denial.js";
+
+describe("isResponsibleUserDenialCode", () => {
+ it("recognizes the two responsible-user denial codes", () => {
+ expect(isResponsibleUserDenialCode("RESPONSIBLE_USER_UNAUTHORIZED")).toBe(true);
+ expect(isResponsibleUserDenialCode("RESPONSIBLE_USER_UNAVAILABLE")).toBe(true);
+ });
+
+ it("rejects agent-lacks-permission and unrelated codes", () => {
+ expect(isResponsibleUserDenialCode("access_denied")).toBe(false);
+ expect(isResponsibleUserDenialCode("deny_missing_membership")).toBe(false);
+ expect(isResponsibleUserDenialCode(null)).toBe(false);
+ expect(isResponsibleUserDenialCode(undefined)).toBe(false);
+ });
+
+ it("covers every exported code", () => {
+ for (const code of RESPONSIBLE_USER_DENIAL_CODES) {
+ expect(isResponsibleUserDenialCode(code)).toBe(true);
+ }
+ });
+});
+
+describe("responsibleUserLabel", () => {
+ it("uses the display name when present", () => {
+ expect(responsibleUserLabel("Ada Lovelace")).toBe("Ada Lovelace");
+ });
+
+ it("falls back to a generic noun, never a raw id, when unknown", () => {
+ expect(responsibleUserLabel(null)).toBe("the responsible user");
+ expect(responsibleUserLabel(undefined)).toBe("the responsible user");
+ expect(responsibleUserLabel(" ")).toBe("the responsible user");
+ });
+});
+
+describe("describeResponsibleUserDenial", () => {
+ it("distinguishes unauthorized (user lacks permission) from unavailable", () => {
+ const unauthorized = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAUTHORIZED");
+ const unavailable = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAVAILABLE");
+
+ expect(unauthorized.tone).toBe("unauthorized");
+ expect(unavailable.tone).toBe("unavailable");
+ expect(unauthorized.title).not.toEqual(unavailable.title);
+ expect(unauthorized.description).not.toEqual(unavailable.description);
+ });
+
+ it("names the responsible user in unauthorized copy when known", () => {
+ const copy = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAUTHORIZED", {
+ userName: "Ada Lovelace",
+ });
+ expect(copy.description).toContain("Ada Lovelace");
+ expect(copy.recommendedAction).toContain("Ada Lovelace");
+ });
+
+ it("uses generic phrasing when the responsible user name is unknown", () => {
+ const copy = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAUTHORIZED");
+ expect(copy.description).toContain("the responsible user");
+ });
+
+ it("steers the unavailable case toward marking work blocked", () => {
+ const copy = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAVAILABLE", {
+ userName: "Grace Hopper",
+ });
+ expect(copy.description).toContain("Grace Hopper");
+ expect(copy.recommendedAction.toLowerCase()).toContain("blocked");
+ });
+
+ it("never uses the word impersonate", () => {
+ for (const code of RESPONSIBLE_USER_DENIAL_CODES) {
+ const copy = describeResponsibleUserDenial(code, { userName: "Someone" });
+ const blob = `${copy.title} ${copy.description} ${copy.recommendedAction}`.toLowerCase();
+ expect(blob).not.toContain("impersonate");
+ }
+ });
+});
diff --git a/packages/shared/src/responsible-user-denial.ts b/packages/shared/src/responsible-user-denial.ts
new file mode 100644
index 0000000000..fb5ac5d47a
--- /dev/null
+++ b/packages/shared/src/responsible-user-denial.ts
@@ -0,0 +1,97 @@
+/**
+ * Copy contract for responsible-user ("on behalf of") authorization denials.
+ *
+ * When an agent run acts on behalf of a human user, authorization is the
+ * intersection of the agent's permissions and that user's permissions
+ * (see PAP-12447 / PAP-12459). When the intersection denies, the authz layer
+ * emits one of the codes below (`AuthorizationDecision.code` in
+ * `server/src/services/authorization.ts`). This module is the single source of
+ * truth for how those codes are explained to humans, so every surface that
+ * renders an agent-call failure uses consistent, actionable language.
+ *
+ * Terminology is deliberate: always "on behalf of {user}" / "responsible user",
+ * never "impersonate".
+ */
+
+export const RESPONSIBLE_USER_DENIAL_CODES = [
+ "RESPONSIBLE_USER_UNAUTHORIZED",
+ "RESPONSIBLE_USER_UNAVAILABLE",
+] as const;
+
+export type ResponsibleUserDenialCode = (typeof RESPONSIBLE_USER_DENIAL_CODES)[number];
+
+export type ResponsibleUserDenialTone = "unauthorized" | "unavailable";
+
+export interface ResponsibleUserDenialCopy {
+ code: ResponsibleUserDenialCode;
+ tone: ResponsibleUserDenialTone;
+ /** Short heading, e.g. for a banner title. */
+ title: string;
+ /** One or two sentences explaining what happened and why. */
+ description: string;
+ /** What the reader should do next. */
+ recommendedAction: string;
+}
+
+export function isResponsibleUserDenialCode(
+ code: string | null | undefined,
+): code is ResponsibleUserDenialCode {
+ return (
+ code === "RESPONSIBLE_USER_UNAUTHORIZED" || code === "RESPONSIBLE_USER_UNAVAILABLE"
+ );
+}
+
+/**
+ * Render a stable label for the responsible user. Falls back to a generic
+ * noun when the display name is unknown, so copy never shows a raw id.
+ */
+export function responsibleUserLabel(userName: string | null | undefined): string {
+ const trimmed = userName?.trim();
+ return trimmed && trimmed.length > 0 ? trimmed : "the responsible user";
+}
+
+/**
+ * Describe a responsible-user denial for display. `userName` is the responsible
+ * user's display name when known; when omitted, generic phrasing is used.
+ *
+ * These two codes are distinct from a plain agent-lacks-permission denial: here
+ * the *agent* is allowed but the *human this run acts for* is not (or is no
+ * longer available). Callers should keep the existing generic agent-permission
+ * copy for denials whose code is neither of these.
+ */
+export function describeResponsibleUserDenial(
+ code: ResponsibleUserDenialCode,
+ options: { userName?: string | null } = {},
+): ResponsibleUserDenialCopy {
+ const who = responsibleUserLabel(options.userName);
+
+ if (code === "RESPONSIBLE_USER_UNAVAILABLE") {
+ return {
+ code,
+ tone: "unavailable",
+ title: "Responsible user unavailable",
+ description:
+ `This run acts on behalf of ${who}, but that account was removed or ` +
+ `deactivated, so its permissions can no longer be evaluated. The agent's ` +
+ `own permissions are not enough on their own — every action still requires ` +
+ `an active responsible user.`,
+ recommendedAction:
+ `Mark the work blocked and reassign a responsible user (or reactivate the ` +
+ `account) before the agent continues.`,
+ };
+ }
+
+ return {
+ code,
+ tone: "unauthorized",
+ title: "Responsible user not authorized",
+ description:
+ `This action was denied because ${who} — the user this run acts on behalf ` +
+ `of — does not have permission to perform it. The agent may be allowed, but ` +
+ `a run can never exceed the permissions of the user it acts for, so the ` +
+ `action is blocked.`,
+ recommendedAction:
+ `Grant ${who} the required permission, or have someone who is authorized ` +
+ `take this action instead.`,
+ };
+}
diff --git a/packages/shared/src/types/company.ts b/packages/shared/src/types/company.ts
index 50c7c5179d..771c63f17f 100644
--- a/packages/shared/src/types/company.ts
+++ b/packages/shared/src/types/company.ts
@@ -12,6 +12,7 @@ export interface Company {
budgetMonthlyCents: number;
spentMonthlyCents: number;
attachmentMaxBytes: number;
+ defaultResponsibleUserId: string | null;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: Date | null;
diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts
index 25442119b6..e8a5b9b519 100644
--- a/packages/shared/src/types/heartbeat.ts
+++ b/packages/shared/src/types/heartbeat.ts
@@ -15,6 +15,7 @@ export interface HeartbeatRun {
invocationSource: HeartbeatInvocationSource;
triggerDetail: WakeupTriggerDetail | null;
status: HeartbeatRunStatus;
+ responsibleUserId: string | null;
startedAt: Date | null;
finishedAt: Date | null;
error: string | null;
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index b31757fa75..8d73510b1c 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -407,9 +407,13 @@ export type {
SecretVersionSelector,
EnvPlainBinding,
EnvSecretRefBinding,
+ EnvUserSecretRefBinding,
EnvBinding,
AgentEnvConfig,
CompanySecret,
+ UserSecretDefinition,
+ UserSecretDeclaration,
+ UserSecretCoverageSummary,
CompanySecretProviderConfig,
SecretProviderConfigPayload,
SecretProviderConfigHealthDetails,
@@ -433,6 +437,7 @@ export type {
SecretAccessOutcome,
SecretBindingTargetType,
SecretManagedMode,
+ SecretScope,
SecretProviderDescriptor,
SecretStatus,
SecretVersionStatus,
diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index 6dc6d44b67..7d446dec7d 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -550,6 +550,7 @@ export interface Issue {
executionLockedAt: Date | null;
createdByAgentId: string | null;
createdByUserId: string | null;
+ responsibleUserId: string | null;
issueNumber: number | null;
identifier: string | null;
originKind?: IssueOriginKind;
diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts
index e708999488..571d7492ef 100644
--- a/packages/shared/src/types/routine.ts
+++ b/packages/shared/src/types/routine.ts
@@ -88,6 +88,7 @@ export interface Routine {
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
+ responsibleUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
lastTriggeredAt: Date | null;
@@ -126,6 +127,7 @@ export interface RoutineRevisionSnapshotRoutineV1 {
originId?: string | null;
variables: RoutineVariable[];
env: RoutineEnvConfig | null;
+ responsibleUserId: string | null;
}
export interface RoutineRevisionSnapshotTriggerV1 {
diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts
index 9c01ff40ff..742e7acd83 100644
--- a/packages/shared/src/types/secrets.ts
+++ b/packages/shared/src/types/secrets.ts
@@ -5,6 +5,7 @@ import type {
SecretProvider,
SecretProviderConfigHealthStatus,
SecretProviderConfigStatus,
+ SecretScope,
SecretStatus,
SecretVersionStatus,
} from "../constants.js";
@@ -16,6 +17,7 @@ export type {
SecretProvider,
SecretProviderConfigHealthStatus,
SecretProviderConfigStatus,
+ SecretScope,
SecretStatus,
SecretVersionStatus,
};
@@ -33,14 +35,25 @@ export interface EnvSecretRefBinding {
version?: SecretVersionSelector;
}
+export interface EnvUserSecretRefBinding {
+ type: "user_secret_ref";
+ key: string;
+ version?: SecretVersionSelector;
+ required?: boolean;
+ allowMissingOverride?: boolean;
+}
+
// Backward-compatible: legacy plaintext string values are still accepted.
-export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding;
+export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding | EnvUserSecretRefBinding;
export type AgentEnvConfig = Record;
export interface CompanySecret {
id: string;
companyId: string;
+ scope: SecretScope;
+ ownerUserId: string | null;
+ userSecretDefinitionId: string | null;
key: string;
name: string;
provider: SecretProvider;
@@ -61,6 +74,50 @@ export interface CompanySecret {
updatedAt: Date;
}
+export interface UserSecretDefinition {
+ id: string;
+ companyId: string;
+ key: string;
+ name: string;
+ description: string | null;
+ status: SecretStatus;
+ provider: SecretProvider;
+ managedMode: SecretManagedMode;
+ providerConfigId: string | null;
+ providerMetadata: Record | null;
+ usageGuidance: string | null;
+ createdByAgentId: string | null;
+ createdByUserId: string | null;
+ updatedByAgentId: string | null;
+ updatedByUserId: string | null;
+ deletedAt: Date | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface UserSecretDeclaration {
+ id: string;
+ companyId: string;
+ userSecretDefinitionId: string;
+ targetType: SecretBindingTargetType;
+ targetId: string;
+ configPath: string;
+ envKey: string;
+ versionSelector: SecretVersionSelector;
+ required: boolean;
+ allowMissingOverride: boolean;
+ label: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface UserSecretCoverageSummary {
+ definitionId: string;
+ configuredCount: number;
+ missingCount: number;
+ inactiveCount: number;
+}
+
export interface SecretProviderDescriptor {
id: SecretProvider;
label: string;
@@ -216,9 +273,15 @@ export interface CompanySecretUsageBinding extends CompanySecretBinding {
export interface SecretAccessEvent {
id: string;
companyId: string;
- secretId: string;
+ secretId: string | null;
+ userSecretDefinitionId: string | null;
+ secretScope: SecretScope;
version: number | null;
provider: SecretProvider;
+ responsibleUserId: string | null;
+ credentialOwnerUserId: string | null;
+ credentialSubjectType: string | null;
+ credentialSubjectId: string | null;
actorType: "agent" | "user" | "system" | "plugin";
actorId: string | null;
consumerType: SecretBindingTargetType;
diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts
index 905591792a..9f682edb2c 100644
--- a/packages/shared/src/validators/company.ts
+++ b/packages/shared/src/validators/company.ts
@@ -18,6 +18,7 @@ export const createCompanySchema = z.object({
description: z.string().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
+ defaultResponsibleUserId: z.string().min(1).nullable().optional(),
});
export type CreateCompany = z.infer;
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 2ddcca2ac2..5cfe48d3cc 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -467,9 +467,16 @@ export {
export {
envBindingPlainSchema,
envBindingSecretRefSchema,
+ envBindingUserSecretRefSchema,
envBindingSchema,
envConfigSchema,
createSecretSchema,
+ createUserSecretDefinitionSchema,
+ updateUserSecretDefinitionSchema,
+ createUserSecretValueSchema,
+ updateUserSecretValueSchema,
+ rotateUserSecretValueSchema,
+ createUserSecretDeclarationSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
secretProviderConfigDiscoveryPreviewSchema,
@@ -487,6 +494,11 @@ export {
updateSecretSchema,
type CreateSecretBinding,
type CreateSecret,
+ type CreateUserSecretDefinition,
+ type UpdateUserSecretDefinition,
+ type CreateUserSecretValue,
+ type UpdateUserSecretValue,
+ type CreateUserSecretDeclaration,
type CreateSecretProviderConfig,
type UpdateSecretProviderConfig,
type SecretProviderConfigDiscoveryPreview,
diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts
index e05eef62b5..8834dcb0bb 100644
--- a/packages/shared/src/validators/issue.test.ts
+++ b/packages/shared/src/validators/issue.test.ts
@@ -48,6 +48,24 @@ describe("issue validators", () => {
expect(parsed.comment).toBe("Done\n\n- Verified the route");
});
+ it("keeps issue attribution fields create-only", () => {
+ const created = createIssueSchema.parse({
+ title: "Preserve attribution input for route checks",
+ createdByUserId: "spoofed-creator",
+ responsibleUserId: "spoofed-responsible",
+ });
+ const updated = updateIssueSchema.parse({
+ title: "Do not update attribution",
+ createdByUserId: "spoofed-creator",
+ responsibleUserId: "spoofed-responsible",
+ });
+
+ expect(created.createdByUserId).toBe("spoofed-creator");
+ expect(created.responsibleUserId).toBe("spoofed-responsible");
+ expect(updated).not.toHaveProperty("createdByUserId");
+ expect(updated).not.toHaveProperty("responsibleUserId");
+ });
+
it("allows false-positive recovery resolutions to atomically restore the source issue status", () => {
expect(
resolveIssueRecoveryActionSchema.parse({
diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts
index b22092ef0d..449f05ad90 100644
--- a/packages/shared/src/validators/issue.ts
+++ b/packages/shared/src/validators/issue.ts
@@ -388,6 +388,8 @@ const createIssueBaseSchema = z.object({
assigneeAgentId: z.string().uuid().optional().nullable(),
assigneeUserId: z.string().optional().nullable(),
requestDepth: issueRequestDepthInputSchema.optional().default(0),
+ createdByUserId: z.string().optional().nullable(),
+ responsibleUserId: z.string().optional().nullable(),
billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
@@ -447,7 +449,11 @@ export const createIssueLabelSchema = z.object({
export type CreateIssueLabel = z.infer;
-export const updateIssueSchema = createIssueBaseSchema.omit({ watchdog: true }).partial().extend({
+export const updateIssueSchema = createIssueBaseSchema.omit({
+ createdByUserId: true,
+ responsibleUserId: true,
+ watchdog: true,
+}).partial().extend({
requestDepth: issueRequestDepthInputSchema.optional(),
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts
index 0506e6a2fb..56629340fd 100644
--- a/packages/shared/src/validators/routine.ts
+++ b/packages/shared/src/validators/routine.ts
@@ -96,6 +96,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES),
variables: z.array(routineVariableSchema),
env: envConfigSchema.nullable().default(null),
+ responsibleUserId: z.string().nullable().default(null),
}).strict();
export const routineRevisionSnapshotTriggerV1Schema = z.object({
diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts
index 10d81a9d58..f25c85d913 100644
--- a/packages/shared/src/validators/secret.test.ts
+++ b/packages/shared/src/validators/secret.test.ts
@@ -1,15 +1,104 @@
import { describe, expect, it } from "vitest";
import {
+ createUserSecretDefinitionSchema,
+ createUserSecretDeclarationSchema,
+ createUserSecretValueSchema,
createSecretProviderConfigSchema,
createSecretSchema,
+ envBindingUserSecretRefSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
+ rotateSecretSchema,
+ rotateUserSecretValueSchema,
secretProviderConfigDiscoveryPreviewSchema,
secretProviderConfigPayloadSchema,
+ updateUserSecretValueSchema,
+ updateUserSecretDefinitionSchema,
updateSecretProviderConfigSchema,
} from "./secret.js";
describe("secret validators", () => {
+ it("defaults user secret refs to required and no missing override", () => {
+ expect(
+ envBindingUserSecretRefSchema.parse({
+ type: "user_secret_ref",
+ key: "github_api_token",
+ }),
+ ).toEqual({
+ type: "user_secret_ref",
+ key: "github_api_token",
+ required: true,
+ allowMissingOverride: false,
+ });
+ });
+
+ it("validates user secret declarations and current-user value payloads", () => {
+ expect(
+ createUserSecretDeclarationSchema.parse({
+ targetType: "agent",
+ targetId: "agent-1",
+ configPath: "env.GITHUB_TOKEN",
+ envKey: "GITHUB_TOKEN",
+ definitionKey: "github_api_token",
+ }),
+ ).toMatchObject({
+ versionSelector: "latest",
+ required: true,
+ allowMissingOverride: false,
+ });
+
+ expect(() =>
+ createUserSecretValueSchema.parse({
+ value: "secret-value",
+ }),
+ ).toThrow(/definitionId or definitionKey/);
+ });
+
+ it("does not allow user secret definition keys to be renamed through updates", () => {
+ expect(
+ updateUserSecretDefinitionSchema.parse({
+ key: "renamed_key",
+ name: "Renamed",
+ }),
+ ).toEqual({
+ name: "Renamed",
+ });
+ });
+
+ it("does not allow user secret definitions to be created as deleted", () => {
+ expect(() =>
+ createUserSecretDefinitionSchema.parse({
+ key: "github_api_token",
+ name: "GitHub API token",
+ status: "deleted",
+ }),
+ ).toThrow();
+ });
+
+ it("requires secret rotation payloads to include rotation input", () => {
+ expect(() => rotateSecretSchema.parse({})).toThrow(/requires value, externalRef/);
+ expect(() => rotateUserSecretValueSchema.parse({})).toThrow(/requires value, externalRef/);
+ expect(() => rotateUserSecretValueSchema.parse({ providerVersionRef: null })).toThrow(/requires value, externalRef/);
+ expect(() => rotateUserSecretValueSchema.parse({ providerConfigId: null })).toThrow(/requires value, externalRef/);
+ expect(() => rotateUserSecretValueSchema.parse({ externalRef: "" })).toThrow();
+ expect(() => rotateUserSecretValueSchema.parse({ providerVersionRef: "" })).toThrow();
+ expect(rotateUserSecretValueSchema.parse({ value: "new-secret" })).toEqual({
+ value: "new-secret",
+ });
+ expect(rotateUserSecretValueSchema.parse({ providerVersionRef: "version-2" })).toEqual({
+ providerVersionRef: "version-2",
+ });
+ });
+
+ it("rejects empty external selectors in user secret patches", () => {
+ expect(() => updateUserSecretValueSchema.parse({ externalRef: "" })).toThrow();
+ expect(() => updateUserSecretValueSchema.parse({ providerVersionRef: "" })).toThrow();
+ expect(updateUserSecretValueSchema.parse({ externalRef: null, providerVersionRef: null })).toEqual({
+ externalRef: null,
+ providerVersionRef: null,
+ });
+ });
+
it("rejects externalRef on managed secrets", () => {
expect(() =>
createSecretSchema.parse({
diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts
index a90bf064f1..e88b4c23d0 100644
--- a/packages/shared/src/validators/secret.ts
+++ b/packages/shared/src/validators/secret.ts
@@ -7,6 +7,10 @@ import {
SECRET_STATUSES,
} from "../constants.js";
+const secretKeySchema = z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/);
+const secretVersionSelectorSchema = z.union([z.literal("latest"), z.number().int().positive()]);
+const creatableSecretStatusSchema = z.enum(["active", "disabled", "archived"]);
+
export const envBindingPlainSchema = z.object({
type: z.literal("plain"),
value: z.string(),
@@ -15,7 +19,15 @@ export const envBindingPlainSchema = z.object({
export const envBindingSecretRefSchema = z.object({
type: z.literal("secret_ref"),
secretId: z.string().uuid(),
- version: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
+ version: secretVersionSelectorSchema.optional(),
+});
+
+export const envBindingUserSecretRefSchema = z.object({
+ type: z.literal("user_secret_ref"),
+ key: secretKeySchema,
+ version: secretVersionSelectorSchema.optional(),
+ required: z.boolean().optional().default(true),
+ allowMissingOverride: z.boolean().optional().default(false),
});
// Backward-compatible union that accepts legacy inline values.
@@ -23,13 +35,14 @@ export const envBindingSchema = z.union([
z.string(),
envBindingPlainSchema,
envBindingSecretRefSchema,
+ envBindingUserSecretRefSchema,
]);
export const envConfigSchema = z.record(z.string(), envBindingSchema);
export const createSecretSchema = z.object({
name: z.string().min(1),
- key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
+ key: secretKeySchema.optional(),
provider: z.enum(SECRET_PROVIDERS).optional(),
providerConfigId: z.string().uuid().optional().nullable(),
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
@@ -67,18 +80,41 @@ export const createSecretSchema = z.object({
export type CreateSecret = z.infer;
+function requireSecretRotationInput(
+ value: {
+ value?: string | null;
+ externalRef?: string | null;
+ providerVersionRef?: string | null;
+ providerConfigId?: string | null;
+ },
+ ctx: z.RefinementCtx,
+) {
+ if (
+ !value.value?.trim() &&
+ !value.externalRef?.trim() &&
+ value.providerVersionRef == null &&
+ value.providerConfigId == null
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["value"],
+ message: "Secret rotation requires value, externalRef, providerVersionRef, or providerConfigId",
+ });
+ }
+}
+
export const rotateSecretSchema = z.object({
value: z.string().min(1).optional().nullable(),
externalRef: z.string().optional().nullable(),
providerVersionRef: z.string().optional().nullable(),
providerConfigId: z.string().uuid().optional().nullable(),
-});
+}).superRefine(requireSecretRotationInput);
export type RotateSecret = z.infer;
export const updateSecretSchema = z.object({
name: z.string().min(1).optional(),
- key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
+ key: secretKeySchema.optional(),
status: z.enum(SECRET_STATUSES).optional(),
providerConfigId: z.string().uuid().optional().nullable(),
description: z.string().optional().nullable(),
@@ -96,13 +132,94 @@ export const secretBindingTargetSchema = z.object({
export const createSecretBindingSchema = secretBindingTargetSchema.extend({
secretId: z.string().uuid(),
- versionSelector: z.union([z.literal("latest"), z.number().int().positive()]).default("latest"),
+ versionSelector: secretVersionSelectorSchema.default("latest"),
required: z.boolean().default(true),
label: z.string().optional().nullable(),
});
export type CreateSecretBinding = z.infer;
+export const createUserSecretDefinitionSchema = z.object({
+ key: secretKeySchema,
+ name: z.string().trim().min(1).max(160),
+ description: z.string().trim().max(500).optional().nullable(),
+ status: creatableSecretStatusSchema.optional(),
+ provider: z.enum(SECRET_PROVIDERS).optional(),
+ providerConfigId: z.string().uuid().optional().nullable(),
+ managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
+ providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
+ usageGuidance: z.string().trim().max(1000).optional().nullable(),
+});
+
+export type CreateUserSecretDefinition = z.infer;
+
+export const updateUserSecretDefinitionSchema = z.object({
+ name: z.string().trim().min(1).max(160).optional(),
+ description: z.string().trim().max(500).optional().nullable(),
+ status: z.enum(SECRET_STATUSES).optional(),
+ providerConfigId: z.string().uuid().optional().nullable(),
+ providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
+ usageGuidance: z.string().trim().max(1000).optional().nullable(),
+});
+
+export type UpdateUserSecretDefinition = z.infer;
+
+export const createUserSecretValueSchema = z.object({
+ definitionKey: secretKeySchema.optional(),
+ definitionId: z.string().uuid().optional(),
+ value: z.string().min(1).optional().nullable(),
+ externalRef: z.string().optional().nullable(),
+ providerVersionRef: z.string().optional().nullable(),
+ providerConfigId: z.string().uuid().optional().nullable(),
+}).superRefine((value, ctx) => {
+ if (!value.definitionKey && !value.definitionId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["definitionId"],
+ message: "User secret value requires definitionId or definitionKey",
+ });
+ }
+ if (!value.value?.trim() && !value.externalRef?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["value"],
+ message: "User secret value requires value or externalRef",
+ });
+ }
+});
+
+export type CreateUserSecretValue = z.infer;
+
+export const updateUserSecretValueSchema = z.object({
+ status: z.enum(SECRET_STATUSES).optional(),
+ value: z.string().min(1).optional().nullable(),
+ externalRef: z.string().min(1).optional().nullable(),
+ providerVersionRef: z.string().min(1).optional().nullable(),
+ providerConfigId: z.string().uuid().optional().nullable(),
+});
+
+export type UpdateUserSecretValue = z.infer;
+
+export const rotateUserSecretValueSchema = z.object({
+ value: z.string().min(1).optional().nullable(),
+ externalRef: z.string().min(1).optional().nullable(),
+ providerVersionRef: z.string().min(1).optional().nullable(),
+ providerConfigId: z.string().uuid().optional().nullable(),
+}).superRefine(requireSecretRotationInput);
+
+export type RotateUserSecretValue = z.infer;
+
+export const createUserSecretDeclarationSchema = secretBindingTargetSchema.extend({
+ definitionKey: secretKeySchema,
+ envKey: z.string().trim().min(1),
+ versionSelector: secretVersionSelectorSchema.default("latest"),
+ required: z.boolean().default(true),
+ allowMissingOverride: z.boolean().default(false),
+ label: z.string().optional().nullable(),
+});
+
+export type CreateUserSecretDeclaration = z.infer;
+
const safeShortText = z.string().trim().min(1).max(160);
const optionalSafeShortText = safeShortText.optional().nullable();
diff --git a/server/src/__tests__/agent-auth-jwt.test.ts b/server/src/__tests__/agent-auth-jwt.test.ts
index 5744be362c..e084b45b21 100644
--- a/server/src/__tests__/agent-auth-jwt.test.ts
+++ b/server/src/__tests__/agent-auth-jwt.test.ts
@@ -47,7 +47,7 @@ describe("agent local JWT", () => {
it("creates and verifies a token", () => {
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
- const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
+ const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1", "user-1");
expect(typeof token).toBe("string");
const claims = verifyLocalAgentJwt(token!);
@@ -56,6 +56,7 @@ describe("agent local JWT", () => {
company_id: "company-1",
adapter_type: "claude_local",
run_id: "run-1",
+ responsible_user_id: "user-1",
iss: "paperclip",
aud: "paperclip-api",
});
diff --git a/server/src/__tests__/agent-auth-middleware.test.ts b/server/src/__tests__/agent-auth-middleware.test.ts
new file mode 100644
index 0000000000..fa2763518e
--- /dev/null
+++ b/server/src/__tests__/agent-auth-middleware.test.ts
@@ -0,0 +1,304 @@
+import { createHash, createHmac, randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ activityLog,
+ agentApiKeys,
+ agents,
+ boardApiKeys,
+ heartbeatRuns,
+} from "@paperclipai/db";
+import { actorMiddleware } from "../middleware/auth.js";
+import { errorHandler } from "../middleware/error-handler.js";
+import { createLocalAgentJwt } from "../agent-auth-jwt.js";
+import { assertCompanyAccess } from "../routes/authz.js";
+
+function hashToken(token: string) {
+ return createHash("sha256").update(token).digest("hex");
+}
+
+function createSelectChain(rowsForTable: (table: unknown) => unknown[]) {
+ return {
+ from(table: unknown) {
+ return {
+ where() {
+ return Promise.resolve(rowsForTable(table));
+ },
+ };
+ },
+ };
+}
+
+function createDbState(input: {
+ agent: { id: string; companyId: string; status?: string };
+ agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null };
+ run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null };
+}) {
+ const activity: Array> = [];
+ const agentRow = {
+ id: input.agent.id,
+ companyId: input.agent.companyId,
+ status: input.agent.status ?? "active",
+ };
+ const keyRow = input.agentKey
+ ? {
+ id: input.agentKey.id,
+ agentId: input.agentKey.agentId,
+ companyId: input.agentKey.companyId,
+ keyHash: input.agentKey.keyHash,
+ responsibleUserId: input.agentKey.responsibleUserId ?? null,
+ revokedAt: null,
+ scopeConfig: null,
+ }
+ : null;
+ const runRow = input.run
+ ? {
+ id: input.run.id,
+ companyId: input.run.companyId,
+ agentId: input.run.agentId,
+ responsibleUserId: input.run.responsibleUserId ?? null,
+ }
+ : null;
+
+ const db = {
+ select: () =>
+ createSelectChain((table) => {
+ if (table === boardApiKeys) return [];
+ if (table === agentApiKeys) return keyRow ? [keyRow] : [];
+ if (table === agents) return [agentRow];
+ if (table === heartbeatRuns) return runRow ? [runRow] : [];
+ return [];
+ }),
+ update: () => ({
+ set() {
+ return {
+ where() {
+ return Promise.resolve([]);
+ },
+ };
+ },
+ }),
+ insert: (table: unknown) => ({
+ values(values: Record) {
+ if (table === activityLog) activity.push(values);
+ return Promise.resolve([]);
+ },
+ }),
+ } as any;
+
+ return { db, activity };
+}
+
+function createApp(db: any) {
+ const app = express();
+ app.use(express.json());
+ app.use(
+ actorMiddleware(db, {
+ deploymentMode: "authenticated",
+ resolveSession: async () => null,
+ }),
+ );
+ app.get("/actor", (req, res) => {
+ res.json(req.actor);
+ });
+ app.get("/companies/:companyId/protected", (req, res) => {
+ assertCompanyAccess(req, req.params.companyId);
+ res.json({ ok: true });
+ });
+ app.use(errorHandler);
+ return app;
+}
+
+function craftAgentJwtWithoutResponsibleClaim(input: {
+ secret: string;
+ agentId: string;
+ companyId: string;
+ adapterType: string;
+ runId: string;
+}) {
+ const now = Math.floor(Date.now() / 1000);
+ const header = { alg: "HS256", typ: "JWT" };
+ const claims = {
+ sub: input.agentId,
+ company_id: input.companyId,
+ adapter_type: input.adapterType,
+ run_id: input.runId,
+ iat: now,
+ exp: now + 3600,
+ iss: "paperclip",
+ aud: "paperclip-api",
+ };
+ const headerB64 = Buffer.from(JSON.stringify(header), "utf8").toString("base64url");
+ const claimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
+ const signingInput = `${headerB64}.${claimsB64}`;
+ const signingKey = createHmac("sha256", input.secret).update(`jwt:${input.companyId}`).digest("hex");
+ const signature = createHmac("sha256", signingKey).update(signingInput).digest("base64url");
+ return `${signingInput}.${signature}`;
+}
+
+describe("agent auth middleware", () => {
+ const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
+ const originalTtl = process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS;
+
+ beforeEach(() => {
+ process.env.PAPERCLIP_AGENT_JWT_SECRET = "auth-middleware-secret";
+ process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS = "3600";
+ });
+
+ afterEach(() => {
+ if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
+ else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
+ if (originalTtl === undefined) delete process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS;
+ else process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS = originalTtl;
+ });
+
+ it("uses the signed responsible_user_id claim and keeps the signed run id authoritative", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const runId = randomUUID();
+ const { db } = createDbState({
+ agent: { id: agentId, companyId },
+ run: { id: runId, companyId, agentId, responsibleUserId: "user-row" },
+ });
+ const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
+
+ const res = await request(createApp(db))
+ .get("/actor")
+ .set("Authorization", `Bearer ${token}`)
+ .set("X-Paperclip-Run-Id", runId);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({
+ type: "agent",
+ agentId,
+ companyId,
+ runId,
+ onBehalfOfUserId: "user-claim",
+ source: "agent_jwt",
+ });
+ });
+
+ it("rejects mismatched run headers for agent JWTs and audits the spoof attempt", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const runId = randomUUID();
+ const spoofedRunId = randomUUID();
+ const { db, activity } = createDbState({
+ agent: { id: agentId, companyId },
+ run: { id: runId, companyId, agentId, responsibleUserId: "user-claim" },
+ });
+ const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim");
+
+ const res = await request(createApp(db))
+ .get("/actor")
+ .set("Authorization", `Bearer ${token}`)
+ .set("X-Paperclip-Run-Id", spoofedRunId);
+
+ expect(res.status).toBe(422);
+ expect(res.body.code).toBe("agent_jwt_run_id_mismatch");
+ expect(activity).toHaveLength(1);
+ expect(activity[0]).toMatchObject({
+ companyId,
+ actorType: "agent",
+ actorId: agentId,
+ action: "auth.agent_jwt_run_header_mismatch",
+ entityType: "heartbeat_run",
+ entityId: runId,
+ runId,
+ details: { claimRunId: runId, headerRunId: spoofedRunId },
+ });
+ });
+
+ it("falls back to the run row responsible user for legacy claim-less agent JWTs", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const runId = randomUUID();
+ const { db } = createDbState({
+ agent: { id: agentId, companyId },
+ run: { id: runId, companyId, agentId, responsibleUserId: "user-legacy" },
+ });
+ const token = craftAgentJwtWithoutResponsibleClaim({
+ secret: process.env.PAPERCLIP_AGENT_JWT_SECRET!,
+ agentId,
+ companyId,
+ adapterType: "codex_local",
+ runId,
+ });
+
+ const res = await request(createApp(db))
+ .get("/actor")
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({
+ type: "agent",
+ runId,
+ onBehalfOfUserId: "user-legacy",
+ source: "agent_jwt",
+ });
+ });
+
+ it("populates agent-key actors from the key responsible user binding", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const token = "pcp_test_agent_key";
+ const { db } = createDbState({
+ agent: { id: agentId, companyId },
+ agentKey: {
+ id: randomUUID(),
+ agentId,
+ companyId,
+ keyHash: hashToken(token),
+ responsibleUserId: "user-key",
+ },
+ });
+
+ const res = await request(createApp(db))
+ .get("/actor")
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({
+ type: "agent",
+ agentId,
+ companyId,
+ onBehalfOfUserId: "user-key",
+ source: "agent_key",
+ });
+ });
+
+ it("rejects agent keys that lack a responsible user binding and audits the denial", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const keyId = randomUUID();
+ const token = "pcp_test_agent_key_without_user";
+ const { db, activity } = createDbState({
+ agent: { id: agentId, companyId },
+ agentKey: {
+ id: keyId,
+ agentId,
+ companyId,
+ keyHash: hashToken(token),
+ responsibleUserId: null,
+ },
+ });
+
+ const res = await request(createApp(db))
+ .get(`/companies/${companyId}/protected`)
+ .set("Authorization", `Bearer ${token}`);
+
+ expect(res.status).toBe(403);
+ expect(res.body.code).toBe("RESPONSIBLE_USER_UNAVAILABLE");
+ expect(activity).toHaveLength(1);
+ expect(activity[0]).toMatchObject({
+ companyId,
+ actorType: "agent",
+ actorId: agentId,
+ action: "auth.agent_key_missing_responsible_user",
+ entityType: "agent_api_key",
+ entityId: keyId,
+ details: { method: "GET", url: `/companies/${companyId}/protected` },
+ });
+ });
+});
diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts
index aba122f285..de0484939e 100644
--- a/server/src/__tests__/authorization-service.test.ts
+++ b/server/src/__tests__/authorization-service.test.ts
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
+ authUsers,
companies,
companyMemberships,
createDb,
@@ -119,6 +120,47 @@ async function grantAgentPermission(
});
}
+async function createUser(
+ db: ReturnType,
+ input: { id?: string; email?: string } = {},
+) {
+ const id = input.id ?? `user-${randomUUID()}`;
+ await db.insert(authUsers).values({
+ id,
+ name: `User ${id}`,
+ email: input.email ?? `${id}@example.com`,
+ emailVerified: true,
+ image: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ return id;
+}
+
+async function grantUserPermission(
+ db: ReturnType,
+ companyId: string,
+ userId: string,
+ permissionKey: "tasks:assign" | "tasks:assign_scope",
+ scope: Record | null = null,
+) {
+ await db.insert(companyMemberships).values({
+ companyId,
+ principalType: "user",
+ principalId: userId,
+ status: "active",
+ membershipRole: "operator",
+ });
+ await db.insert(principalPermissionGrants).values({
+ companyId,
+ principalType: "user",
+ principalId: userId,
+ permissionKey,
+ scope,
+ grantedByUserId: "owner",
+ });
+}
+
describeEmbeddedPostgres("authorization service", () => {
let db!: ReturnType;
let tempDb: Awaited> | null = null;
@@ -137,6 +179,7 @@ describeEmbeddedPostgres("authorization service", () => {
await db.delete(agents);
await db.delete(projects);
await db.delete(companies);
+ await db.delete(authUsers);
});
afterAll(async () => {
@@ -254,6 +297,179 @@ describeEmbeddedPostgres("authorization service", () => {
expect(decision.explanation).toContain("simple mode");
});
+ it("denies delegated protected assignment when the responsible user lacks matching authority", async () => {
+ const company = await createCompany(db, "ResponsibleUserDenied");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const ceoAgent = await createAgent(db, company.id, {
+ role: "ceo",
+ permissions: {
+ authorizationPolicy: {
+ assignmentPolicy: { mode: "protected" },
+ },
+ },
+ });
+ const responsibleUserId = await createUser(db);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign");
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: responsibleUserId,
+ status: "active",
+ membershipRole: "operator",
+ });
+
+ const decision = await authorizationService(db).decide({
+ actor: {
+ type: "agent",
+ agentId: actorAgent.id,
+ companyId: company.id,
+ onBehalfOfUserId: responsibleUserId,
+ source: "agent_jwt",
+ },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: ceoAgent.id },
+ scope: { assigneeAgentId: ceoAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: false,
+ code: "RESPONSIBLE_USER_UNAUTHORIZED",
+ });
+ });
+
+ it("allows active non-viewer responsible users to authorize assigned agent issue mutations", async () => {
+ const company = await createCompany(db, "ResponsibleUserIssueMutation");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const issue = await createIssue(db, company.id, {
+ title: "Assigned issue mutation",
+ assigneeAgentId: actorAgent.id,
+ });
+ const responsibleUserId = await createUser(db);
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: responsibleUserId,
+ status: "active",
+ membershipRole: "operator",
+ });
+
+ await expect(authorizationService(db).decide({
+ actor: {
+ type: "agent",
+ agentId: actorAgent.id,
+ companyId: company.id,
+ onBehalfOfUserId: responsibleUserId,
+ source: "agent_jwt",
+ },
+ action: "issue:mutate",
+ resource: {
+ type: "issue",
+ companyId: company.id,
+ issueId: issue.id,
+ assigneeAgentId: actorAgent.id,
+ },
+ })).resolves.toMatchObject({
+ allowed: true,
+ reason: "allow_self",
+ });
+ });
+
+ it("keeps responsible-user issue mutations denied for viewer memberships", async () => {
+ const company = await createCompany(db, "ResponsibleUserIssueViewerDenied");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const issue = await createIssue(db, company.id, {
+ title: "Assigned viewer-denied mutation",
+ assigneeAgentId: actorAgent.id,
+ });
+ const responsibleUserId = await createUser(db);
+ await db.insert(companyMemberships).values({
+ companyId: company.id,
+ principalType: "user",
+ principalId: responsibleUserId,
+ status: "active",
+ membershipRole: "viewer",
+ });
+
+ await expect(authorizationService(db).decide({
+ actor: {
+ type: "agent",
+ agentId: actorAgent.id,
+ companyId: company.id,
+ onBehalfOfUserId: responsibleUserId,
+ source: "agent_jwt",
+ },
+ action: "issue:mutate",
+ resource: {
+ type: "issue",
+ companyId: company.id,
+ issueId: issue.id,
+ assigneeAgentId: actorAgent.id,
+ },
+ })).resolves.toMatchObject({
+ allowed: false,
+ code: "RESPONSIBLE_USER_UNAUTHORIZED",
+ reason: "deny_unsupported_action",
+ });
+ });
+
+ it("fails closed when the responsible user is unavailable", async () => {
+ const company = await createCompany(db, "ResponsibleUserUnavailable");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const targetAgent = await createAgent(db, company.id, { role: "engineer" });
+
+ const decision = await authorizationService(db).decide({
+ actor: {
+ type: "agent",
+ agentId: actorAgent.id,
+ companyId: company.id,
+ onBehalfOfUserId: `missing-${randomUUID()}`,
+ source: "agent_jwt",
+ },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
+ scope: { assigneeAgentId: targetAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: false,
+ code: "RESPONSIBLE_USER_UNAVAILABLE",
+ });
+ });
+
+ it("allows delegated protected assignment when both agent and responsible user are authorized", async () => {
+ const company = await createCompany(db, "ResponsibleUserAllowed");
+ const actorAgent = await createAgent(db, company.id, { role: "engineer" });
+ const ceoAgent = await createAgent(db, company.id, {
+ role: "ceo",
+ permissions: {
+ authorizationPolicy: {
+ assignmentPolicy: { mode: "protected" },
+ },
+ },
+ });
+ const responsibleUserId = await createUser(db);
+ await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign");
+ await grantUserPermission(db, company.id, responsibleUserId, "tasks:assign");
+
+ const decision = await authorizationService(db).decide({
+ actor: {
+ type: "agent",
+ agentId: actorAgent.id,
+ companyId: company.id,
+ onBehalfOfUserId: responsibleUserId,
+ source: "agent_jwt",
+ },
+ action: "tasks:assign",
+ resource: { type: "issue", companyId: company.id, assigneeAgentId: ceoAgent.id },
+ scope: { assigneeAgentId: ceoAgent.id },
+ });
+
+ expect(decision).toMatchObject({
+ allowed: true,
+ reason: "allow_explicit_grant",
+ });
+ });
+
it("limits low-trust issue reads to the configured project and root issue boundary", async () => {
const company = await createCompany(db, "LowTrustIssueReads");
const project = await createProject(db, company.id, "Allowed");
diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts
index b4b713592a..d389d168a3 100644
--- a/server/src/__tests__/authz-company-access.test.ts
+++ b/server/src/__tests__/authz-company-access.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { HttpError } from "../errors.js";
import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js";
function makeReq(input: {
@@ -104,6 +105,93 @@ describe("assertCompanyAccess", () => {
expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
});
+
+ it("fails closed when an on-behalf-of agent lacks a responsible user membership snapshot", () => {
+ const req = makeReq({
+ method: "GET",
+ actor: {
+ type: "agent",
+ agentId: "agent-1",
+ companyId: "company-1",
+ onBehalfOfUserId: "user-1",
+ onBehalfOfMemberships: [],
+ source: "agent_jwt",
+ },
+ });
+
+ expect(() => assertCompanyAccess(req, "company-1")).toThrow(HttpError);
+ try {
+ assertCompanyAccess(req, "company-1");
+ } catch (err) {
+ expect((err as HttpError).details).toMatchObject({ code: "RESPONSIBLE_USER_UNAVAILABLE" });
+ }
+ });
+
+ it("rejects on-behalf-of agent writes when the responsible user is read-only", () => {
+ const req = makeReq({
+ method: "PATCH",
+ actor: {
+ type: "agent",
+ agentId: "agent-1",
+ companyId: "company-1",
+ onBehalfOfUserId: "user-1",
+ onBehalfOfMemberships: [
+ { companyId: "company-1", membershipRole: "viewer", status: "active" },
+ ],
+ source: "agent_jwt",
+ },
+ });
+
+ try {
+ assertCompanyAccess(req, "company-1");
+ } catch (err) {
+ expect((err as HttpError).status).toBe(403);
+ expect((err as HttpError).details).toMatchObject({ code: "RESPONSIBLE_USER_UNAUTHORIZED" });
+ return;
+ }
+ throw new Error("Expected responsible-user company access denial");
+ });
+
+ it("logs only in shadow mode for responsible-user company access denials", () => {
+ const previous = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW;
+ process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = "true";
+ try {
+ const req = makeReq({
+ method: "PATCH",
+ actor: {
+ type: "agent",
+ agentId: "agent-1",
+ companyId: "company-1",
+ onBehalfOfUserId: "user-1",
+ onBehalfOfMemberships: [],
+ source: "agent_jwt",
+ },
+ });
+
+ expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
+ } finally {
+ if (previous === undefined) delete process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW;
+ else process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = previous;
+ }
+ });
+
+ it("allows on-behalf-of agent writes for active non-viewer responsible users", () => {
+ const req = makeReq({
+ method: "PATCH",
+ actor: {
+ type: "agent",
+ agentId: "agent-1",
+ companyId: "company-1",
+ onBehalfOfUserId: "user-1",
+ onBehalfOfMemberships: [
+ { companyId: "company-1", membershipRole: "operator", status: "active" },
+ ],
+ source: "agent_jwt",
+ },
+ });
+
+ expect(() => assertCompanyAccess(req, "company-1")).not.toThrow();
+ });
});
describe("assertBoardOrgAccess", () => {
diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts
index 483403e047..3d58f4fa27 100644
--- a/server/src/__tests__/error-handler.test.ts
+++ b/server/src/__tests__/error-handler.test.ts
@@ -1,8 +1,14 @@
import type { NextFunction, Request, Response } from "express";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
+const recordResponsibleUserDenialOnActiveRunMock = vi.hoisted(() => vi.fn());
+
+vi.mock("../services/responsible-user-denial-run-outcomes.js", () => ({
+ recordResponsibleUserDenialOnActiveRun: recordResponsibleUserDenialOnActiveRunMock,
+}));
+
function makeReq(): Request {
return {
method: "GET",
@@ -23,6 +29,11 @@ function makeRes(): Response {
}
describe("errorHandler", () => {
+ beforeEach(() => {
+ recordResponsibleUserDenialOnActiveRunMock.mockReset();
+ recordResponsibleUserDenialOnActiveRunMock.mockResolvedValue(null);
+ });
+
it("attaches the original Error to res.err for 500s", () => {
const req = makeReq();
const res = makeRes() as any;
@@ -75,4 +86,39 @@ describe("errorHandler", () => {
expect(res.err).toBe(err);
expect(res.__errorContext?.error?.message).toBe("db exploded");
});
+
+ it("records responsible-user denial codes on the active agent run", () => {
+ const db = { marker: "db" };
+ const req = {
+ ...makeReq(),
+ app: { locals: { paperclipDb: db } },
+ actor: {
+ type: "agent",
+ agentId: "agent-1",
+ companyId: "company-1",
+ runId: "run-1",
+ source: "agent_jwt",
+ },
+ } as unknown as Request;
+ const res = makeRes();
+ const next = vi.fn() as unknown as NextFunction;
+ const err = new HttpError(403, "Responsible user is not authorized", {
+ code: "RESPONSIBLE_USER_UNAUTHORIZED",
+ });
+
+ errorHandler(err, req, res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: "Responsible user is not authorized",
+ code: "RESPONSIBLE_USER_UNAUTHORIZED",
+ details: { code: "RESPONSIBLE_USER_UNAUTHORIZED" },
+ });
+ expect(recordResponsibleUserDenialOnActiveRunMock).toHaveBeenCalledWith(db, {
+ runId: "run-1",
+ agentId: "agent-1",
+ companyId: "company-1",
+ code: "RESPONSIBLE_USER_UNAUTHORIZED",
+ });
+ });
});
diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts
index 3eddcf8d7b..277ada7a15 100644
--- a/server/src/__tests__/file-resources.test.ts
+++ b/server/src/__tests__/file-resources.test.ts
@@ -28,6 +28,7 @@ import {
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const execFileAsync = promisify(execFile);
+let seedGraphSequence = 0;
type TestGraph = {
companyId: string;
@@ -63,8 +64,9 @@ async function seedGraph(db: Db, input: {
projectSourceType?: string;
targetProjectSourceType?: string;
}): Promise {
- const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
- const prefixSuffix = suffix.toUpperCase();
+ seedGraphSequence += 1;
+ const prefixSuffix = seedGraphSequence.toString(36).toUpperCase().padStart(4, "0");
+ const suffix = crypto.randomUUID().slice(0, 8);
const companyId = crypto.randomUUID();
const otherCompanyId = crypto.randomUUID();
const goalId = crypto.randomUUID();
diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
index 0b99064dc1..7fcc8f49a3 100644
--- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
+++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
@@ -216,6 +216,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -274,6 +275,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
status: "in_progress",
workMode: "planning",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-9122",
executionWorkspaceId: sharedExecutionWorkspaceId,
@@ -389,6 +391,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -433,6 +436,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
status: "in_progress",
workMode: "planning",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-9301",
createdAt: new Date(),
@@ -447,6 +451,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
status: "in_progress",
workMode: "planning",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-9302",
createdAt: new Date(),
@@ -545,6 +550,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -589,6 +595,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
status: "in_progress",
workMode: "standard",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-9401",
createdAt: new Date(),
@@ -603,6 +610,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
status: "in_progress",
workMode: "planning",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-9402",
createdAt: new Date(),
@@ -702,6 +710,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -745,6 +754,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
status: "in_progress",
workMode: "planning",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
identifier: "PAP-9303",
createdAt: new Date(),
diff --git a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
index 15d278c7e2..de08684702 100644
--- a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
+++ b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
@@ -147,6 +147,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
id: companyId,
name: "Watchdog Co",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts
index 1a137cbf06..3e6d30fe8c 100644
--- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts
+++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts
@@ -192,6 +192,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -231,6 +232,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Hire an agent",
status: "blocked",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
executionRunId: runId,
executionAgentNameKey: "ceo",
@@ -307,6 +309,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -336,6 +339,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Batch wake comments",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -506,6 +510,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -535,6 +540,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Interrupt queued comment",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
@@ -652,6 +658,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -681,6 +688,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Reopen after deferred comment",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -845,6 +853,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values([
@@ -896,6 +905,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Do not reopen from agent mention",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -1044,6 +1054,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -1073,6 +1084,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Self-comment must not reopen",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -1210,6 +1222,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -1239,6 +1252,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Human follow-up must survive mixed deferred batches",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -1421,6 +1435,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -1450,6 +1465,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Require a comment",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -1571,6 +1587,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values([
@@ -1622,6 +1639,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Prevent concurrent mention execution",
status: "todo",
priority: "high",
+ responsibleUserId: "responsible-user",
assigneeAgentId: primaryAgentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -1772,6 +1790,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values([
@@ -1823,6 +1842,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Mention should not steal execution ownership",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: primaryAgentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
@@ -1919,6 +1939,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -1948,6 +1969,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
title: "Use existing comment",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts
index 6bf716d508..6efdd356ea 100644
--- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts
+++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts
@@ -167,6 +167,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
@@ -191,6 +192,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
title: "Mission 0",
status: "todo",
priority: "high",
+ responsibleUserId: "responsible-user",
},
{
id: blockedIssueId,
@@ -199,6 +201,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
{
id: readyIssueId,
@@ -207,6 +210,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "critical",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
]);
await db.insert(issueRelations).values({
@@ -545,6 +549,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
@@ -570,6 +575,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "high",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
{
id: secondIssueId,
@@ -578,6 +584,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "high",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
]);
@@ -679,6 +686,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
@@ -703,6 +711,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
title: "Security review",
status: "blocked",
priority: "high",
+ responsibleUserId: "responsible-user",
},
{
id: blockedIssueId,
@@ -711,6 +720,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "blocked",
priority: "medium",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
{
id: readyIssueId,
@@ -719,6 +729,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "low",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
]);
await db.insert(issueRelations).values({
@@ -875,6 +886,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
@@ -900,6 +912,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
},
...issueChain.map((issueId, index) => ({
id: issueId,
@@ -909,6 +922,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
})),
]);
const [hold] = await db
@@ -1004,6 +1018,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
@@ -1028,6 +1043,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
+ responsibleUserId: "responsible-user",
});
await db.insert(issueTreeHolds).values({
companyId,
diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts
index a1352b6e08..abb9fa8c41 100644
--- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts
+++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts
@@ -7,6 +7,7 @@ import {
agentWakeupRequests,
budgetPolicies,
companies,
+ companyMemberships,
costEvents,
createDb,
executionWorkspaces,
@@ -215,6 +216,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
const workspaceState = opts.workspaceState ?? "none";
const companyId = randomUUID();
const agentId = randomUUID();
+ const ownerUserId = randomUUID();
const blockedIssueId = randomUUID();
const blockerIssueId = randomUUID();
const projectId = randomUUID();
@@ -228,6 +230,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
+ await db.insert(companyMemberships).values({
+ companyId,
+ principalType: "user",
+ principalId: ownerUserId,
+ membershipRole: "owner",
+ status: "active",
+ });
await db.insert(agents).values({
id: agentId,
companyId,
diff --git a/server/src/__tests__/heartbeat-local-environment.test.ts b/server/src/__tests__/heartbeat-local-environment.test.ts
index 5edac6113b..a84eb97014 100644
--- a/server/src/__tests__/heartbeat-local-environment.test.ts
+++ b/server/src/__tests__/heartbeat-local-environment.test.ts
@@ -97,6 +97,7 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts
index ee7155bb78..3a592ae92b 100644
--- a/server/src/__tests__/heartbeat-plugin-environment.test.ts
+++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts
@@ -113,6 +113,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -345,6 +346,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
name: "Acme A",
issuePrefix: `T${companyAId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user-a",
createdAt: new Date(),
updatedAt: new Date(),
},
@@ -353,6 +355,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
name: "Acme B",
issuePrefix: `T${companyBId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user-b",
createdAt: new Date(),
updatedAt: new Date(),
},
@@ -513,6 +516,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
@@ -639,6 +643,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => {
title: "Environment matrix: e2b / codex_local",
status: "in_progress",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
executionWorkspaceId: staleExecutionWorkspaceId,
executionWorkspaceSettings: {
diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts
index 2d7b3b016e..a340df5059 100644
--- a/server/src/__tests__/heartbeat-process-recovery.test.ts
+++ b/server/src/__tests__/heartbeat-process-recovery.test.ts
@@ -467,6 +467,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
@@ -525,6 +526,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
assigneeAgentId: agentId,
checkoutRunId: runId,
executionRunId: runId,
+ responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
@@ -665,6 +667,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
@@ -734,6 +737,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
title: "Paused recovery root",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
}]
@@ -749,6 +753,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
assigneeUserId: input.assignToUser ? "user-1" : null,
checkoutRunId: input.status === "in_progress" ? runId : null,
executionRunId: null,
+ responsibleUserId: "responsible-user",
issueNumber: input.activePauseHold ? 2 : 1,
identifier: `${issuePrefix}-${input.activePauseHold ? 2 : 1}`,
startedAt: input.status === "in_progress" ? now : null,
@@ -787,6 +792,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
@@ -847,6 +853,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
executionRunId: runId,
executionAgentNameKey: "codexreviewer",
executionLockedAt: now,
+ responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
executionState: {
@@ -878,6 +885,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
@@ -901,6 +909,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
priority: "medium",
assigneeAgentId: agentId,
assigneeUserId: null,
+ responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
@@ -1050,6 +1059,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
@@ -1110,6 +1120,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
assigneeAgentId: agentId,
checkoutRunId: runId,
executionRunId: runId,
+ responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
startedAt: now,
@@ -3358,6 +3369,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
@@ -3392,6 +3404,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
status: "todo",
priority: "high",
createdByAgentId: creatorAgentId,
+ responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
},
@@ -3402,6 +3415,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
status: "blocked",
priority: "high",
assigneeAgentId: blockedAssigneeAgentId,
+ responsibleUserId: "responsible-user",
issueNumber: 2,
identifier: `${issuePrefix}-2`,
},
@@ -3485,6 +3499,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts
index 3fd5112a32..8de2204140 100644
--- a/server/src/__tests__/heartbeat-project-env.test.ts
+++ b/server/src/__tests__/heartbeat-project-env.test.ts
@@ -1,4 +1,7 @@
-import { describe, expect, it, vi } from "vitest";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { buildSkillMentionHref } from "@paperclipai/shared";
import {
LOW_TRUST_REVIEW_PRESET,
@@ -283,6 +286,72 @@ describe("resolveExecutionRunAdapterConfig", () => {
});
});
+ it("blocks required missing user secrets before runtime env resolution", async () => {
+ const resolveAdapterConfigForRuntime = vi.fn();
+ const resolveEnvBindings = vi.fn();
+ const collectMissingRuntimeBindings = vi.fn(async (_companyId, _env, context) =>
+ context.consumerType === "agent"
+ ? [
+ {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ configPath: "env.GITHUB_TOKEN",
+ envKey: "GITHUB_TOKEN",
+ bindingType: "user_secret_ref",
+ secretId: null,
+ secretName: null,
+ userSecretDefinitionId: "definition-1",
+ userSecretDefinitionKey: "github_token",
+ userSecretDefinitionName: "GitHub token",
+ responsibleUserId: context.responsibleUserId,
+ errorCode: "user_secret_missing",
+ },
+ ]
+ : [],
+ );
+
+ await expect(resolveExecutionRunAdapterConfig({
+ companyId: "company-1",
+ agentId: "agent-1",
+ issueId: "issue-1",
+ heartbeatRunId: "run-1",
+ responsibleUserId: "user-1",
+ executionRunConfig: {
+ env: {
+ GITHUB_TOKEN: { type: "user_secret_ref", key: "github_token", required: true },
+ },
+ },
+ projectEnv: null,
+ secretsSvc: {
+ resolveAdapterConfigForRuntime,
+ resolveEnvBindings,
+ collectMissingRuntimeBindings,
+ } as any,
+ })).rejects.toMatchObject({
+ code: "configuration_incomplete",
+ resultJson: {
+ configurationIncomplete: {
+ reason: "secret_binding_missing",
+ companyId: "company-1",
+ agentId: "agent-1",
+ issueId: "issue-1",
+ missingBindings: [
+ expect.objectContaining({
+ bindingType: "user_secret_ref",
+ userSecretDefinitionKey: "github_token",
+ responsibleUserId: "user-1",
+ }),
+ ],
+ },
+ },
+ });
+ expect(collectMissingRuntimeBindings.mock.calls[0]?.[2]).toMatchObject({
+ responsibleUserId: "user-1",
+ });
+ expect(resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
+ expect(resolveEnvBindings).not.toHaveBeenCalled();
+ });
+
it("rejects inline sensitive env values for low-trust runs", async () => {
await expect(resolveExecutionRunAdapterConfig({
companyId: "company-1",
@@ -391,6 +460,172 @@ describe("resolveExecutionRunAdapterConfig", () => {
});
});
+describe("resolveExecutionRunAdapterConfig codex_local credential pre-dispatch gate", () => {
+ const cleanupDirs: string[] = [];
+
+ afterEach(async () => {
+ vi.unstubAllEnvs();
+ while (cleanupDirs.length > 0) {
+ const dir = cleanupDirs.pop();
+ if (!dir) continue;
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
+ }
+ });
+
+ async function stubManagedCodexEnv(options: { seedSharedAuth: boolean }) {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-gate-"));
+ cleanupDirs.push(root);
+ const paperclipHome = path.join(root, "paperclip-home");
+ const sharedCodexHome = path.join(root, "shared-codex-home");
+ await fs.mkdir(sharedCodexHome, { recursive: true });
+ if (options.seedSharedAuth) {
+ await fs.writeFile(
+ path.join(sharedCodexHome, "auth.json"),
+ '{"OPENAI_API_KEY":"sk-shared"}\n',
+ "utf8",
+ );
+ }
+ vi.stubEnv("PAPERCLIP_HOME", paperclipHome);
+ vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
+ vi.stubEnv("CODEX_HOME", sharedCodexHome);
+ const managedAgentHome = path.join(
+ paperclipHome,
+ "instances",
+ "default",
+ "companies",
+ "company-1",
+ "agents",
+ "agent-1",
+ "codex-home",
+ );
+ return { root, managedAgentHome };
+ }
+
+ it("surfaces a configuration-incomplete blocker when a managed home has no auth and OPENAI_API_KEY is empty", async () => {
+ const { managedAgentHome } = await stubManagedCodexEnv({ seedSharedAuth: false });
+ const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
+ config: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } },
+ secretKeys: new Set(),
+ manifest: [],
+ });
+
+ await expect(
+ resolveExecutionRunAdapterConfig({
+ companyId: "company-1",
+ agentId: "agent-1",
+ adapterType: "codex_local",
+ issueId: "issue-1",
+ responsibleUserId: "user-1",
+ executionRunConfig: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } },
+ projectEnv: null,
+ secretsSvc: {
+ resolveAdapterConfigForRuntime,
+ resolveEnvBindings: vi.fn(),
+ collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]),
+ } as any,
+ }),
+ ).rejects.toMatchObject({
+ code: "configuration_incomplete",
+ message: expect.stringContaining("no Codex credentials available"),
+ resultJson: {
+ configurationIncomplete: {
+ reason: "codex_credentials_missing",
+ adapterType: "codex_local",
+ companyId: "company-1",
+ agentId: "agent-1",
+ issueId: "issue-1",
+ responsibleUserId: "user-1",
+ requiredEnvKeys: ["OPENAI_API_KEY"],
+ },
+ },
+ });
+ // The blocker message must not leak any secret value.
+ await expect(
+ resolveExecutionRunAdapterConfig({
+ companyId: "company-1",
+ agentId: "agent-1",
+ adapterType: "codex_local",
+ executionRunConfig: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } },
+ projectEnv: null,
+ secretsSvc: {
+ resolveAdapterConfigForRuntime,
+ resolveEnvBindings: vi.fn(),
+ collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]),
+ } as any,
+ }).catch((err) => err.message),
+ ).resolves.not.toContain("sk-");
+ });
+
+ it("dispatches normally when a per-agent OPENAI_API_KEY is resolved", async () => {
+ const { managedAgentHome } = await stubManagedCodexEnv({ seedSharedAuth: false });
+ const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
+ config: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "sk-agent-resolved" } },
+ secretKeys: new Set(["OPENAI_API_KEY"]),
+ manifest: [],
+ });
+
+ const result = await resolveExecutionRunAdapterConfig({
+ companyId: "company-1",
+ agentId: "agent-1",
+ adapterType: "codex_local",
+ executionRunConfig: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: { type: "secret_ref" } } },
+ projectEnv: null,
+ secretsSvc: {
+ resolveAdapterConfigForRuntime,
+ resolveEnvBindings: vi.fn(),
+ collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]),
+ } as any,
+ });
+ expect(result.resolvedConfig.env).toMatchObject({ OPENAI_API_KEY: "sk-agent-resolved" });
+ });
+
+ it("dispatches normally when the shared host home carries subscription auth", async () => {
+ const { managedAgentHome } = await stubManagedCodexEnv({ seedSharedAuth: true });
+ const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
+ config: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } },
+ secretKeys: new Set(),
+ manifest: [],
+ });
+
+ const result = await resolveExecutionRunAdapterConfig({
+ companyId: "company-1",
+ agentId: "agent-1",
+ adapterType: "codex_local",
+ executionRunConfig: { command: "codex", env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" } },
+ projectEnv: null,
+ secretsSvc: {
+ resolveAdapterConfigForRuntime,
+ resolveEnvBindings: vi.fn(),
+ collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]),
+ } as any,
+ });
+ expect(result.resolvedConfig.command).toBe("codex");
+ });
+
+ it("does not gate non-codex adapters", async () => {
+ await stubManagedCodexEnv({ seedSharedAuth: false });
+ const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
+ config: { command: "claude", env: { OPENAI_API_KEY: "" } },
+ secretKeys: new Set(),
+ manifest: [],
+ });
+
+ const result = await resolveExecutionRunAdapterConfig({
+ companyId: "company-1",
+ agentId: "agent-1",
+ adapterType: "claude_local",
+ executionRunConfig: { command: "claude", env: { OPENAI_API_KEY: "" } },
+ projectEnv: null,
+ secretsSvc: {
+ resolveAdapterConfigForRuntime,
+ resolveEnvBindings: vi.fn(),
+ collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]),
+ } as any,
+ });
+ expect(result.resolvedConfig.command).toBe("claude");
+ });
+});
+
describe("extractMentionedSkillIdsFromSources", () => {
it("collects UUID skill mention ids across issue sources", () => {
const releaseSkillId = "11111111-1111-4111-8111-111111111111";
diff --git a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts
new file mode 100644
index 0000000000..e90b3fb771
--- /dev/null
+++ b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts
@@ -0,0 +1,306 @@
+import { randomUUID } from "node:crypto";
+import { and, eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ activityLog,
+ agents,
+ agentRuntimeState,
+ agentWakeupRequests,
+ companies,
+ companyMemberships,
+ companySkills,
+ createDb,
+ heartbeatRunEvents,
+ heartbeatRuns,
+ issueComments,
+ issues,
+} from "@paperclipai/db";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+import { heartbeatService } from "../services/heartbeat.ts";
+import { runningProcesses } from "../adapters/index.ts";
+
+const mockAdapterExecute = vi.hoisted(() =>
+ vi.fn(async () => ({
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ errorMessage: null,
+ summary: "Responsible-user invariant test run.",
+ provider: "test",
+ model: "test-model",
+ })),
+);
+
+vi.mock("../adapters/index.ts", async () => {
+ const actual = await vi.importActual("../adapters/index.ts");
+ return {
+ ...actual,
+ getServerAdapter: vi.fn(() => ({
+ supportsLocalAgentJwt: false,
+ execute: mockAdapterExecute,
+ })),
+ };
+});
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+async function waitForRun(db: ReturnType, runId: string) {
+ for (let attempt = 0; attempt < 80; attempt += 1) {
+ const run = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
+ if (run && run.status !== "queued" && run.status !== "running") return run;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ return db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
+}
+
+describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
+ let db!: ReturnType;
+ let heartbeat!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-responsible-user-");
+ db = createDb(tempDb.connectionString);
+ heartbeat = heartbeatService(db);
+ }, 20_000);
+
+ afterEach(async () => {
+ mockAdapterExecute.mockClear();
+ runningProcesses.clear();
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ const activeRuns = await db
+ .select()
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.status, "running"));
+ if (activeRuns.length === 0) break;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ await db.delete(heartbeatRunEvents);
+ await db.delete(issueComments);
+ await db.delete(activityLog);
+ await db.delete(heartbeatRuns);
+ await db.delete(agentWakeupRequests);
+ await db.delete(agentRuntimeState);
+ await db.delete(issues);
+ await db.delete(agents);
+ await db.delete(companySkills);
+ await db.delete(companyMemberships);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ async function seedCompany() {
+ const companyId = randomUUID();
+ const ownerUserId = `owner-${randomUUID()}`;
+ const agentId = randomUUID();
+
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ defaultResponsibleUserId: ownerUserId,
+ });
+ await db.insert(companyMemberships).values({
+ companyId,
+ principalType: "user",
+ principalId: ownerUserId,
+ membershipRole: "owner",
+ status: "active",
+ });
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "CodexCoder",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
+ permissions: {},
+ });
+
+ return { companyId, ownerUserId, agentId };
+ }
+
+ it("uses the issue responsible user for comment, mention, and dependency wakes", async () => {
+ const { companyId, agentId } = await seedCompany();
+ const issueResponsibleUserId = `issue-owner-${randomUUID()}`;
+ const commenterUserId = `commenter-${randomUUID()}`;
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Issue-owned work",
+ status: "todo",
+ assigneeAgentId: agentId,
+ responsibleUserId: issueResponsibleUserId,
+ });
+
+ for (const wakeReason of ["issue_commented", "issue_comment_mentioned", "issue_blockers_resolved"]) {
+ const run = await heartbeat.wakeup(agentId, {
+ source: "automation",
+ triggerDetail: "system",
+ reason: wakeReason,
+ payload: { issueId, commentId: randomUUID() },
+ requestedByActorType: "user",
+ requestedByActorId: commenterUserId,
+ contextSnapshot: { issueId, taskId: issueId, wakeReason },
+ });
+ expect(run).not.toBeNull();
+ const completed = await waitForRun(db, run!.id);
+ expect(completed?.responsibleUserId).toBe(issueResponsibleUserId);
+ }
+ });
+
+ it("uses the triggering user for manual UI/API runs", async () => {
+ const { agentId } = await seedCompany();
+ const triggeringUserId = `manual-${randomUUID()}`;
+ const run = await heartbeat.wakeup(agentId, {
+ source: "on_demand",
+ triggerDetail: "manual",
+ requestedByActorType: "user",
+ requestedByActorId: triggeringUserId,
+ });
+
+ expect(run).not.toBeNull();
+ const completed = await waitForRun(db, run!.id);
+ expect(completed?.responsibleUserId).toBe(triggeringUserId);
+ });
+
+ it("falls back to the company default for system-originated runs without an issue", async () => {
+ const { agentId, ownerUserId } = await seedCompany();
+ const run = await heartbeat.wakeup(agentId, {
+ source: "automation",
+ triggerDetail: "system",
+ reason: "productivity_review",
+ requestedByActorType: "system",
+ requestedByActorId: null,
+ contextSnapshot: { wakeReason: "productivity_review" },
+ });
+
+ expect(run).not.toBeNull();
+ const completed = await waitForRun(db, run!.id);
+ expect(completed?.responsibleUserId).toBe(ownerUserId);
+ });
+
+ it("does not use an issue creator as an implicit responsible user for automated issue runs", async () => {
+ const { companyId, agentId, ownerUserId } = await seedCompany();
+ const creatorUserId = `creator-${randomUUID()}`;
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Creator is not credential owner",
+ status: "todo",
+ assigneeAgentId: agentId,
+ createdByUserId: creatorUserId,
+ });
+
+ const run = await heartbeat.wakeup(agentId, {
+ source: "automation",
+ triggerDetail: "system",
+ reason: "issue_commented",
+ payload: { issueId, commentId: randomUUID() },
+ requestedByActorType: "user",
+ requestedByActorId: `commenter-${randomUUID()}`,
+ contextSnapshot: { issueId, taskId: issueId, wakeReason: "issue_commented" },
+ });
+ expect(run).not.toBeNull();
+ const completed = await waitForRun(db, run!.id);
+ expect(completed?.responsibleUserId).toBe(ownerUserId);
+ expect(completed?.responsibleUserId).not.toBe(creatorUserId);
+ });
+
+ it("fails automated issue dispatch instead of falling back to the issue creator when no default exists", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const issueId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Creator-only",
+ issuePrefix: `C${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ });
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "CodexCoder",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: { heartbeat: { wakeOnDemand: true } },
+ permissions: {},
+ });
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Creator-only issue",
+ status: "todo",
+ assigneeAgentId: agentId,
+ createdByUserId: `creator-${randomUUID()}`,
+ });
+
+ await expect(heartbeat.wakeup(agentId, {
+ source: "automation",
+ triggerDetail: "system",
+ reason: "issue_commented",
+ payload: { issueId, commentId: randomUUID() },
+ requestedByActorType: "user",
+ requestedByActorId: `commenter-${randomUUID()}`,
+ contextSnapshot: { issueId, taskId: issueId, wakeReason: "issue_commented" },
+ })).rejects.toMatchObject({
+ status: 422,
+ details: { code: "responsible_user_unresolved" },
+ });
+
+ const runs = await db
+ .select()
+ .from(heartbeatRuns)
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
+ expect(runs).toHaveLength(0);
+ });
+
+ it("fails dispatch before creating a run when no responsible user can be resolved", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Ownerless",
+ issuePrefix: `O${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ });
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "CodexCoder",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: { heartbeat: { wakeOnDemand: true } },
+ permissions: {},
+ });
+
+ await expect(heartbeat.wakeup(agentId, {
+ source: "automation",
+ triggerDetail: "system",
+ requestedByActorType: "system",
+ })).rejects.toMatchObject({
+ status: 422,
+ details: { code: "responsible_user_unresolved" },
+ });
+
+ const runs = await db
+ .select()
+ .from(heartbeatRuns)
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
+ expect(runs).toHaveLength(0);
+ });
+});
diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts
index 4ff58f503d..4e59091057 100644
--- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts
+++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts
@@ -82,6 +82,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${input.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -152,6 +153,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -205,6 +207,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
title: "Continue after max turns",
status: input?.issueStatus ?? "in_progress",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
executionRunId: runId,
executionAgentNameKey: "claudecoder",
@@ -227,6 +230,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -690,6 +694,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
title: "Blocker",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
issueNumber: 2,
identifier: `T${dependencyBlocked.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-2`,
});
@@ -734,6 +739,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values([
@@ -795,6 +801,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
title: "Retry reassignment",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: oldAgentId,
executionRunId: sourceRunId,
executionAgentNameKey: "claudecoder",
@@ -887,6 +894,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values([
@@ -948,6 +956,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
title: "Retry promotion reassignment",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: oldAgentId,
executionRunId: sourceRunId,
executionAgentNameKey: "claudecoder",
@@ -1004,6 +1013,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -1047,6 +1057,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
title: "Retry human handoff",
status: "in_progress",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: oldAgentId,
executionRunId: sourceRunId,
executionAgentNameKey: "claudecoder",
@@ -1112,6 +1123,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
@@ -1155,6 +1167,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
title: "Retry promotion cancellation",
status: "todo",
priority: "medium",
+ responsibleUserId: "responsible-user",
assigneeAgentId: agentId,
executionRunId: sourceRunId,
executionAgentNameKey: "codexcoder",
@@ -1210,6 +1223,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
diff --git a/server/src/__tests__/heartbeat-runtime-skills.test.ts b/server/src/__tests__/heartbeat-runtime-skills.test.ts
index e5a5492846..293cc94701 100644
--- a/server/src/__tests__/heartbeat-runtime-skills.test.ts
+++ b/server/src/__tests__/heartbeat-runtime-skills.test.ts
@@ -121,6 +121,7 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Runtime Coach\n\nVersion one.\n", "utf8");
await db.insert(companySkills).values({
diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts
index 9c0f2732e6..b560d82f22 100644
--- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts
+++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts
@@ -190,6 +190,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
diff --git a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts
index 13fe1a77f6..fb30b06cfd 100644
--- a/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts
+++ b/server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts
@@ -152,6 +152,7 @@ async function seedRunTarget(db: Db, repoRoot: string) {
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
+ defaultResponsibleUserId: "responsible-user",
createdAt: new Date(),
updatedAt: new Date(),
});
diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
index e810f3085a..0e5ea769ed 100644
--- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
+++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
@@ -132,6 +132,7 @@ const mockExternalObjectService = vi.hoisted(() => ({
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
+const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -169,7 +170,7 @@ function registerRouteMocks() {
}));
vi.doMock("../services/activity-log.js", () => ({
- logActivity: vi.fn(async () => undefined),
+ logActivity: mockLogActivity,
}));
vi.doMock("../services/index.js", () => ({
@@ -216,7 +217,7 @@ function registerRouteMocks() {
issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService,
taskWatchdogService: () => mockTaskWatchdogService,
- logActivity: vi.fn(async () => undefined),
+ logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
@@ -495,6 +496,7 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset();
mockIssueService.findMentionedAgents.mockReset();
+ mockLogActivity.mockClear();
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.createForIssue.mockReset();
mockExternalObjectService.getIssueSummaries.mockClear();
@@ -1223,6 +1225,159 @@ describe("agent issue mutation checkout ownership", () => {
);
});
+ it("rejects agent-created issues that supply responsibleUserId", async () => {
+ const app = await createApp(ownerActor());
+
+ const res = await request(app)
+ .post(`/api/companies/${companyId}/issues`)
+ .send({
+ title: "Spoof responsible user",
+ responsibleUserId: "spoofed-user",
+ });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(422);
+ expect(res.body.error).toContain("responsibleUserId");
+ expect(mockIssueService.create).not.toHaveBeenCalled();
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ companyId,
+ actorType: "agent",
+ actorId: ownerAgentId,
+ action: "issue.attribution_spoof_rejected",
+ entityType: "company",
+ details: expect.objectContaining({
+ surface: "issues.create",
+ field: "responsibleUserId",
+ requestedValue: "spoofed-user",
+ }),
+ }),
+ );
+ });
+
+ it("strips agent-supplied createdByUserId and derives attribution from the authenticated actor", async () => {
+ const app = await createApp(ownerActor());
+
+ const res = await request(app)
+ .post(`/api/companies/${companyId}/issues`)
+ .send({
+ title: "Spoof creator",
+ createdByUserId: "spoofed-user",
+ });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ expect(mockIssueService.create).toHaveBeenCalledWith(
+ companyId,
+ expect.objectContaining({
+ title: "Spoof creator",
+ createdByAgentId: ownerAgentId,
+ createdByUserId: null,
+ actorRunId: ownerRunId,
+ }),
+ );
+ expect(mockIssueService.create).toHaveBeenCalledWith(
+ companyId,
+ expect.not.objectContaining({
+ createdByUserId: "spoofed-user",
+ }),
+ );
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ companyId,
+ actorType: "agent",
+ actorId: ownerAgentId,
+ action: "issue.attribution_spoof_stripped",
+ details: expect.objectContaining({
+ surface: "issues.create",
+ field: "createdByUserId",
+ requestedValue: "spoofed-user",
+ }),
+ }),
+ );
+ });
+
+ it("allows board-created issues to pass explicit responsibleUserId as trusted attribution", async () => {
+ const app = await createApp(boardActor());
+
+ const res = await request(app)
+ .post(`/api/companies/${companyId}/issues`)
+ .send({
+ title: "Board-owned work",
+ responsibleUserId: "responsible-board-user",
+ });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ expect(mockIssueService.create).toHaveBeenCalledWith(
+ companyId,
+ expect.objectContaining({
+ title: "Board-owned work",
+ responsibleUserId: "responsible-board-user",
+ createdByUserId: "board-user",
+ trustExplicitResponsibleUserId: true,
+ }),
+ );
+ });
+
+ it("rejects agent-created child issues that supply responsibleUserId", async () => {
+ const app = await createApp(ownerActor());
+
+ const res = await request(app)
+ .post(`/api/issues/${issueId}/children`)
+ .send({
+ title: "Spoof child responsible user",
+ responsibleUserId: "spoofed-user",
+ });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(422);
+ expect(mockIssueService.createChild).not.toHaveBeenCalled();
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ companyId,
+ action: "issue.attribution_spoof_rejected",
+ entityType: "issue",
+ entityId: issueId,
+ details: expect.objectContaining({
+ surface: "issues.children.create",
+ field: "responsibleUserId",
+ }),
+ }),
+ );
+ });
+
+ it("rejects accepted-plan child creation when an agent child body supplies responsibleUserId", async () => {
+ const app = await createApp(ownerActor());
+
+ const res = await request(app)
+ .post(`/api/issues/${issueId}/accepted-plan-decompositions`)
+ .send({
+ acceptedPlanRevisionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ children: [
+ {
+ title: "Spoof plan child responsible user",
+ responsibleUserId: "spoofed-user",
+ },
+ ],
+ });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(422);
+ expect(mockIssueService.decomposeAcceptedPlan).not.toHaveBeenCalled();
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ companyId,
+ action: "issue.attribution_spoof_rejected",
+ entityType: "issue",
+ entityId: issueId,
+ details: expect.objectContaining({
+ surface: "issues.accepted_plan_decomposition",
+ field: "responsibleUserId",
+ }),
+ }),
+ );
+ });
+
it("allows board users to set explicit cheap issue assignee profile overrides", async () => {
const app = await createApp(boardActor());
diff --git a/server/src/__tests__/issue-monitor-scheduler.test.ts b/server/src/__tests__/issue-monitor-scheduler.test.ts
index 16fef429aa..f682f682f5 100644
--- a/server/src/__tests__/issue-monitor-scheduler.test.ts
+++ b/server/src/__tests__/issue-monitor-scheduler.test.ts
@@ -160,6 +160,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts
index 5be080a5aa..9463c734e3 100644
--- a/server/src/__tests__/issue-thread-interactions-service.test.ts
+++ b/server/src/__tests__/issue-thread-interactions-service.test.ts
@@ -103,6 +103,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
const goalId = randomUUID();
const issueId = randomUUID();
const assigneeAgentId = randomUUID();
+ const responsibleUserId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -138,6 +139,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
status: "in_progress",
priority: "medium",
requestDepth: 2,
+ responsibleUserId,
});
const created = await interactionsSvc.create({
@@ -200,6 +202,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
.select({
title: issues.title,
workMode: issues.workMode,
+ responsibleUserId: issues.responsibleUserId,
})
.from(issues)
.where(eq(issues.companyId, companyId));
@@ -209,6 +212,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect.objectContaining({ title: "Create the nested follow-up", workMode: "standard" }),
]),
);
+ expect(createdIssueRows).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ title: "Create the root follow-up", responsibleUserId }),
+ expect.objectContaining({ title: "Create the nested follow-up", responsibleUserId }),
+ ]),
+ );
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts
index b56785ff38..39cd067b09 100644
--- a/server/src/__tests__/issues-service.test.ts
+++ b/server/src/__tests__/issues-service.test.ts
@@ -2292,6 +2292,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
+ await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(environments);
await db.delete(instanceSettings);
@@ -2378,6 +2379,104 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
});
});
+ it("inherits responsible user for agent-created child issues", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const runId = randomUUID();
+ const responsibleUserId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "Coder",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ });
+
+ const parent = await svc.create(companyId, {
+ title: "Parent issue",
+ status: "in_progress",
+ priority: "medium",
+ assigneeAgentId: agentId,
+ createdByUserId: responsibleUserId,
+ });
+ await db.insert(heartbeatRuns).values({
+ id: runId,
+ companyId,
+ agentId,
+ invocationSource: "assignment",
+ status: "running",
+ responsibleUserId,
+ contextSnapshot: { issueId: parent.id },
+ });
+
+ const child = await svc.create(companyId, {
+ parentId: parent.id,
+ title: "Agent-created child",
+ createdByAgentId: agentId,
+ actorRunId: runId,
+ });
+
+ expect(parent.responsibleUserId).toBe(responsibleUserId);
+ expect(child.responsibleUserId).toBe(responsibleUserId);
+ });
+
+ it("only honors explicit responsibleUserId for trusted issue create callers", async () => {
+ const companyId = randomUUID();
+ const creatorUserId = randomUUID();
+ const requestedResponsibleUserId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+
+ const untrusted = await svc.create(companyId, {
+ title: "Untrusted explicit responsible user",
+ createdByUserId: creatorUserId,
+ responsibleUserId: requestedResponsibleUserId,
+ });
+ const trusted = await svc.create(companyId, {
+ title: "Trusted explicit responsible user",
+ createdByUserId: creatorUserId,
+ responsibleUserId: requestedResponsibleUserId,
+ trustExplicitResponsibleUserId: true,
+ });
+
+ expect(untrusted.responsibleUserId).toBe(creatorUserId);
+ expect(trusted.responsibleUserId).toBe(requestedResponsibleUserId);
+ });
+
+ it("derives responsible user from authenticated actor context without trusting issue body", async () => {
+ const companyId = randomUUID();
+ const actorResponsibleUserId = randomUUID();
+ const requestedResponsibleUserId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+
+ const issue = await svc.create(companyId, {
+ title: "Actor-context responsible user",
+ responsibleUserId: requestedResponsibleUserId,
+ actorResponsibleUserId,
+ });
+
+ expect(issue.responsibleUserId).toBe(actorResponsibleUserId);
+ });
+
it("does not stamp the assignee default environment onto new issues", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts
index ecd9b78682..bf972d0251 100644
--- a/server/src/__tests__/low-trust-red-team-routes.test.ts
+++ b/server/src/__tests__/low-trust-red-team-routes.test.ts
@@ -317,6 +317,7 @@ async function seedLowTrustFixture(db: Db) {
const [company] = await db.insert(companies).values({
name: `Low trust ${nonce}`,
issuePrefix: `LT${nonce.slice(0, 4).toUpperCase()}`,
+ defaultResponsibleUserId: "board-user",
}).returning();
const [allowedProject] = await db.insert(projects).values({
companyId: company!.id,
@@ -364,6 +365,7 @@ async function seedLowTrustFixture(db: Db) {
title: "Review root",
status: "todo",
priority: "medium",
+ responsibleUserId: "board-user",
}).returning();
const [assignedReview] = await db.insert(issues).values({
companyId: company!.id,
@@ -372,6 +374,7 @@ async function seedLowTrustFixture(db: Db) {
title: "Assigned low-trust review",
status: "in_progress",
priority: "medium",
+ responsibleUserId: "board-user",
}).returning();
const [sameBoundaryChild] = await db.insert(issues).values({
companyId: company!.id,
@@ -380,6 +383,7 @@ async function seedLowTrustFixture(db: Db) {
title: "Same boundary child",
status: "todo",
priority: "medium",
+ responsibleUserId: "board-user",
}).returning();
const [siblingOutOfScope] = await db.insert(issues).values({
companyId: company!.id,
@@ -388,6 +392,7 @@ async function seedLowTrustFixture(db: Db) {
description: canaries.issueSibling,
status: "todo",
priority: "medium",
+ responsibleUserId: "board-user",
}).returning();
const [lowTrust] = await db.insert(agents).values({
@@ -596,6 +601,7 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
status: "in_progress",
priority: "medium",
assigneeAgentId: fixture.agents.standard.id,
+ responsibleUserId: "board-user",
}).returning();
await db.insert(issueComments).values({
companyId: fixture.company.id,
@@ -1033,6 +1039,7 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
const [otherCompany] = await db.insert(companies).values({
name: "Foreign low-trust source",
issuePrefix: `FGN${randomUUID().slice(0, 4).toUpperCase()}`,
+ defaultResponsibleUserId: "board-user",
}).returning();
const [foreignIssue] = await db.insert(issues).values({
companyId: otherCompany!.id,
diff --git a/server/src/__tests__/pipelines-service.test.ts b/server/src/__tests__/pipelines-service.test.ts
index 847750d182..bd63341402 100644
--- a/server/src/__tests__/pipelines-service.test.ts
+++ b/server/src/__tests__/pipelines-service.test.ts
@@ -90,6 +90,7 @@ describeEmbeddedPostgres("pipelineService", () => {
const [company] = await db.insert(companies).values({
name: "Pipeline Co",
issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ defaultResponsibleUserId: "board-user",
}).returning();
return company!;
}
diff --git a/server/src/__tests__/plugin-managed-routines.test.ts b/server/src/__tests__/plugin-managed-routines.test.ts
index 97f93ac6f0..e478cf68b3 100644
--- a/server/src/__tests__/plugin-managed-routines.test.ts
+++ b/server/src/__tests__/plugin-managed-routines.test.ts
@@ -136,6 +136,7 @@ describeEmbeddedPostgres("plugin-managed routines", () => {
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(plugins).values({
id: pluginId,
diff --git a/server/src/__tests__/qa-routine-secrets-e2e.test.ts b/server/src/__tests__/qa-routine-secrets-e2e.test.ts
index d1b11d2c3f..11b009ba14 100644
--- a/server/src/__tests__/qa-routine-secrets-e2e.test.ts
+++ b/server/src/__tests__/qa-routine-secrets-e2e.test.ts
@@ -91,6 +91,7 @@ describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => {
name: "QA Co",
issuePrefix,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
// Note: executor agent has NO secret bindings of its own — this is the
// whole point of routine env (the secret rides with the routine, not the agent).
diff --git a/server/src/__tests__/routine-run-telemetry.test.ts b/server/src/__tests__/routine-run-telemetry.test.ts
index f5e77ac2b3..3b5a96955b 100644
--- a/server/src/__tests__/routine-run-telemetry.test.ts
+++ b/server/src/__tests__/routine-run-telemetry.test.ts
@@ -84,6 +84,7 @@ describeEmbeddedPostgres("routine run telemetry", () => {
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
+ defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts
index 183913f227..cf8e0eebbd 100644
--- a/server/src/__tests__/routines-service.test.ts
+++ b/server/src/__tests__/routines-service.test.ts
@@ -104,6 +104,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
+ const defaultResponsibleUserId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const wakeups: Array<{
agentId: string;
@@ -122,6 +123,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
id: companyId,
name: "Paperclip",
issuePrefix,
+ defaultResponsibleUserId,
requireBoardApprovalForNewAgents: false,
});
@@ -154,6 +156,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
+ const issue = await db
+ .select({ responsibleUserId: issues.responsibleUserId })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0] ?? null);
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
@@ -162,6 +169,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
+ responsibleUserId: issue?.responsibleUserId ?? defaultResponsibleUserId,
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
@@ -712,6 +720,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
id: issues.id,
assigneeAgentId: issues.assigneeAgentId,
createdByUserId: issues.createdByUserId,
+ responsibleUserId: issues.responsibleUserId,
})
.from(issues)
.where(eq(issues.id, run.linkedIssueId!));
@@ -719,6 +728,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
id: run.linkedIssueId,
assigneeAgentId: agentId,
createdByUserId: userId,
+ responsibleUserId: userId,
});
const inboxIssues = await issueSvc.list(companyId, {
@@ -729,6 +739,45 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
});
+ it("uses the routine revision responsible-user snapshot for automatic runs", async () => {
+ const { companyId, agentId, projectId, svc } = await seedFixture();
+ const responsibleUserId = randomUUID();
+ const driftUserId = randomUUID();
+ const routine = await svc.create(
+ companyId,
+ {
+ projectId,
+ goalId: null,
+ parentIssueId: null,
+ title: "snapshotted owner routine",
+ description: null,
+ assigneeAgentId: agentId,
+ priority: "medium",
+ status: "active",
+ concurrencyPolicy: "coalesce_if_active",
+ catchUpPolicy: "skip_missed",
+ },
+ { userId: responsibleUserId },
+ );
+
+ await db
+ .update(routines)
+ .set({ responsibleUserId: driftUserId, updatedAt: new Date() })
+ .where(eq(routines.id, routine.id));
+
+ const run = await svc.runRoutine(routine.id, { source: "schedule" });
+
+ expect(run.status).toBe("issue_created");
+ expect(run.responsibleUserId).toBe(responsibleUserId);
+ const [createdIssue] = await db
+ .select({
+ responsibleUserId: issues.responsibleUserId,
+ })
+ .from(issues)
+ .where(eq(issues.id, run.linkedIssueId!));
+ expect(createdIssue?.responsibleUserId).toBe(responsibleUserId);
+ });
+
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
let wakeupResolved = false;
const { routine, svc } = await seedFixture({
diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts
index 11c556aa71..fc878c0280 100644
--- a/server/src/__tests__/secrets-routes.test.ts
+++ b/server/src/__tests__/secrets-routes.test.ts
@@ -19,8 +19,19 @@ const mockSecretService = vi.hoisted(() => ({
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
+ rotate: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
+ listUserSecretDefinitions: vi.fn(),
+ createUserSecretDefinition: vi.fn(),
+ updateUserSecretDefinition: vi.fn(),
+ removeUserSecretDefinition: vi.fn(),
+ getUserSecretDefinitionCoverage: vi.fn(),
+ listCurrentUserSecretValues: vi.fn(),
+ createCurrentUserSecretValue: vi.fn(),
+ updateCurrentUserSecretValue: vi.fn(),
+ rotateCurrentUserSecretValue: vi.fn(),
+ removeCurrentUserSecretValue: vi.fn(),
previewRemoteImport: vi.fn(),
importRemoteSecrets: vi.fn(),
}));
@@ -95,6 +106,170 @@ describe("secret routes", () => {
expect(mockSecretService.create).not.toHaveBeenCalled();
});
+ it("restricts user secret definition management to company admins", async () => {
+ const res = await request(createApp({
+ type: "board",
+ userId: "user-1",
+ source: "session",
+ companyIds: ["company-1"],
+ memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
+ })).post("/api/companies/company-1/user-secret-definitions").send({
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ });
+
+ expect(res.status).toBe(403);
+ expect(mockSecretService.createUserSecretDefinition).not.toHaveBeenCalled();
+ });
+
+ it("records implicit user-secret definition admins as system actors instead of board pseudo-users", async () => {
+ mockSecretService.createUserSecretDefinition.mockResolvedValue({
+ id: "definition-1",
+ companyId: "company-1",
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ status: "active",
+ });
+
+ const res = await request(createApp({
+ type: "board",
+ source: "local_implicit",
+ isInstanceAdmin: true,
+ companyIds: ["company-1"],
+ memberships: [],
+ })).post("/api/companies/company-1/user-secret-definitions").send({
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ });
+
+ expect(res.status).toBe(201);
+ expect(mockSecretService.createUserSecretDefinition).toHaveBeenCalledWith(
+ "company-1",
+ expect.objectContaining({ key: "github_token" }),
+ { userId: null, agentId: null },
+ );
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ actorType: "system",
+ actorId: "local_implicit",
+ action: "user_secret_definition.created",
+ }),
+ );
+ expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("\"board\"");
+ });
+
+ it("logs patched user-secret definition deletion as deletion activity", async () => {
+ mockSecretService.updateUserSecretDefinition.mockResolvedValue({
+ id: "definition-1",
+ companyId: "company-1",
+ key: "github_token__deleted__definition-1",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ status: "deleted",
+ });
+
+ const res = await request(createApp())
+ .patch("/api/companies/company-1/user-secret-definitions/definition-1")
+ .send({ status: "deleted" });
+
+ expect(res.status).toBe(200);
+ expect(mockSecretService.updateUserSecretDefinition).toHaveBeenCalledWith(
+ "company-1",
+ "definition-1",
+ expect.objectContaining({ status: "deleted" }),
+ { userId: "user-1", agentId: null },
+ );
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ action: "user_secret_definition.deleted",
+ entityType: "user_secret_definition",
+ entityId: "definition-1",
+ }),
+ );
+ });
+
+ it("creates current-user secret values for the authenticated user only", async () => {
+ mockSecretService.createCurrentUserSecretValue.mockResolvedValue({
+ id: "secret-1",
+ companyId: "company-1",
+ scope: "user",
+ ownerUserId: "user-1",
+ userSecretDefinitionId: "definition-1",
+ provider: "local_encrypted",
+ latestVersion: 1,
+ });
+
+ const res = await request(createApp()).post("/api/companies/company-1/me/user-secrets").send({
+ definitionKey: "github_token",
+ value: "secret-value",
+ });
+
+ expect(res.status).toBe(201);
+ expect(mockSecretService.createCurrentUserSecretValue).toHaveBeenCalledWith(
+ "company-1",
+ "user-1",
+ {
+ definitionKey: "github_token",
+ definitionId: undefined,
+ value: "secret-value",
+ externalRef: undefined,
+ providerVersionRef: undefined,
+ providerConfigId: undefined,
+ },
+ { userId: "user-1", agentId: null },
+ );
+ expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("secret-value");
+ });
+
+ it("rejects current-user secret values without a concrete user identity", async () => {
+ const res = await request(createApp({
+ type: "board",
+ source: "local_implicit",
+ companyIds: ["company-1"],
+ memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
+ })).post("/api/companies/company-1/me/user-secrets").send({
+ definitionKey: "github_token",
+ value: "secret-value",
+ });
+
+ expect(res.status).toBe(401);
+ expect(res.body).toMatchObject({ error: "User identity required for user-specific secrets" });
+ expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled();
+ expect(mockLogActivity).not.toHaveBeenCalled();
+ });
+
+ it("rejects empty current-user secret rotation payloads", async () => {
+ const res = await request(createApp())
+ .post("/api/companies/company-1/me/user-secrets/secret-1/rotate")
+ .send({});
+
+ expect(res.status).toBe(400);
+ expect(JSON.stringify(res.body)).toMatch(/requires value, externalRef/);
+ expect(mockSecretService.rotateCurrentUserSecretValue).not.toHaveBeenCalled();
+ });
+
+ it("hides user-scoped secrets from legacy company secret mutation routes", async () => {
+ mockSecretService.getById.mockResolvedValue({
+ id: "secret-1",
+ companyId: "company-1",
+ scope: "user",
+ ownerUserId: "user-2",
+ status: "active",
+ });
+
+ const res = await request(createApp()).post("/api/secrets/secret-1/rotate").send({
+ value: "new-secret-value",
+ });
+
+ expect(res.status).toBe(404);
+ expect(mockSecretService.rotate).not.toHaveBeenCalled();
+ });
+
it("rejects provider vault routes for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts
index eda8c2f953..bd85967228 100644
--- a/server/src/__tests__/secrets-service.test.ts
+++ b/server/src/__tests__/secrets-service.test.ts
@@ -14,6 +14,8 @@ import {
companySecrets,
createDb,
secretAccessEvents,
+ userSecretDeclarations,
+ userSecretDefinitions,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
@@ -47,9 +49,11 @@ describeEmbeddedPostgres("secretService", () => {
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(secretAccessEvents);
+ await db.delete(userSecretDeclarations);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
+ await db.delete(userSecretDefinitions);
await db.delete(companySecretProviderConfigs);
await db.delete(companyMemberships);
await db.delete(agents);
@@ -342,6 +346,56 @@ describeEmbeddedPostgres("secretService", () => {
expect(resolved.manifest[0]?.bindingId).toBe(binding!.id);
});
+ it("denies user secret resolution outside the low-trust declaration allowlist", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ const svc = secretService(db);
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ });
+ const env = {
+ GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
+ };
+
+ await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
+ await svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionKey: "github_token",
+ value: "user-one-secret",
+ });
+ const [declaration] = await db
+ .select()
+ .from(userSecretDeclarations)
+ .where(eq(userSecretDeclarations.userSecretDefinitionId, definition.id));
+ expect(declaration?.id).toBeTruthy();
+
+ await expect(
+ svc.resolveEnvBindings(companyId, env, {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ actorType: "agent",
+ actorId: "agent-1",
+ responsibleUserId: "user-1",
+ allowedBindingIds: ["11111111-1111-4111-8111-111111111111"],
+ }),
+ ).rejects.toMatchObject({
+ status: 422,
+ details: { code: "binding_not_allowed" },
+ });
+
+ const resolved = await svc.resolveEnvBindings(companyId, env, {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ actorType: "agent",
+ actorId: "agent-1",
+ responsibleUserId: "user-1",
+ allowedBindingIds: [declaration!.id],
+ });
+ expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
+ expect(resolved.manifest[0]?.bindingId).toBe(declaration!.id);
+ });
+
it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -387,6 +441,576 @@ describeEmbeddedPostgres("secretService", () => {
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
});
+ it("resolves user secret refs through responsible-user values and records owner metadata", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ await seedCompanyMember(companyId, "user-2", "member");
+ const svc = secretService(db);
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ });
+ const env = {
+ GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
+ };
+
+ await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
+ const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionKey: "github_token",
+ value: "user-one-secret",
+ });
+
+ await expect(
+ svc.resolveEnvBindings(companyId, env, {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ actorType: "agent",
+ actorId: "agent-1",
+ responsibleUserId: "user-2",
+ }),
+ ).rejects.toThrow(/not configured/i);
+ await expect(
+ svc.collectMissingRuntimeBindings(companyId, env, {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ responsibleUserId: "user-2",
+ }),
+ ).resolves.toEqual([
+ expect.objectContaining({
+ bindingType: "user_secret_ref",
+ configPath: "env.GITHUB_TOKEN",
+ envKey: "GITHUB_TOKEN",
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: "github_token",
+ responsibleUserId: "user-2",
+ errorCode: "user_secret_missing",
+ }),
+ ]);
+
+ const optionalEnv = {
+ OPTIONAL_GITHUB_TOKEN: {
+ type: "user_secret_ref" as const,
+ key: "github_token",
+ version: "latest" as const,
+ required: false,
+ },
+ };
+ await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-optional" }, optionalEnv);
+ await expect(
+ svc.collectMissingRuntimeBindings(companyId, optionalEnv, {
+ consumerType: "agent",
+ consumerId: "agent-optional",
+ responsibleUserId: "user-2",
+ }),
+ ).resolves.toEqual([]);
+ await expect(
+ svc.resolveEnvBindings(companyId, optionalEnv, {
+ consumerType: "agent",
+ consumerId: "agent-optional",
+ actorType: "agent",
+ actorId: "agent-optional",
+ responsibleUserId: "user-2",
+ }),
+ ).resolves.toMatchObject({
+ env: {},
+ manifest: [],
+ });
+
+ await db
+ .update(userSecretDefinitions)
+ .set({ status: "disabled" })
+ .where(eq(userSecretDefinitions.id, definition.id));
+ await expect(
+ svc.collectMissingRuntimeBindings(companyId, env, {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ responsibleUserId: "user-2",
+ }),
+ ).resolves.toEqual([
+ expect.objectContaining({
+ bindingType: "user_secret_ref",
+ configPath: "env.GITHUB_TOKEN",
+ envKey: "GITHUB_TOKEN",
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: "github_token",
+ userSecretDefinitionName: "GitHub token",
+ responsibleUserId: "user-2",
+ errorCode: "user_secret_definition_inactive",
+ }),
+ ]);
+ await expect(
+ svc.resolveEnvBindings(companyId, optionalEnv, {
+ consumerType: "agent",
+ consumerId: "agent-optional",
+ actorType: "agent",
+ actorId: "agent-optional",
+ responsibleUserId: "user-2",
+ }),
+ ).resolves.toMatchObject({
+ env: {},
+ manifest: [],
+ });
+ await db
+ .update(userSecretDefinitions)
+ .set({ status: "deleted", deletedAt: new Date() })
+ .where(eq(userSecretDefinitions.id, definition.id));
+ await expect(
+ svc.resolveEnvBindings(companyId, optionalEnv, {
+ consumerType: "agent",
+ consumerId: "agent-optional",
+ actorType: "agent",
+ actorId: "agent-optional",
+ responsibleUserId: "user-2",
+ }),
+ ).resolves.toMatchObject({
+ env: {},
+ manifest: [],
+ });
+ await db
+ .update(userSecretDefinitions)
+ .set({ status: "active", deletedAt: null })
+ .where(eq(userSecretDefinitions.id, definition.id));
+
+ const resolved = await svc.resolveEnvBindings(companyId, env, {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ actorType: "agent",
+ actorId: "agent-1",
+ responsibleUserId: "user-1",
+ });
+
+ expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
+ expect(resolved.manifest[0]).toMatchObject({
+ configPath: "env.GITHUB_TOKEN",
+ envKey: "GITHUB_TOKEN",
+ secretId: userOneSecret.id,
+ secretKey: userOneSecret.key,
+ outcome: "success",
+ });
+ expect((await svc.list(companyId)).map((secret) => secret.id)).not.toContain(userOneSecret.id);
+ await expect(
+ svc.resolveSecretValue(companyId, userOneSecret.id, "latest", {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ configPath: "env.GITHUB_TOKEN",
+ }),
+ ).rejects.toThrow(/User-scoped secrets/i);
+
+ const events = await db
+ .select()
+ .from(secretAccessEvents)
+ .where(eq(secretAccessEvents.secretId, userOneSecret.id));
+ expect(events).toHaveLength(1);
+ expect(events[0]).toMatchObject({
+ companyId,
+ secretId: userOneSecret.id,
+ userSecretDefinitionId: definition.id,
+ secretScope: "user",
+ responsibleUserId: "user-1",
+ credentialOwnerUserId: "user-1",
+ credentialSubjectType: "user",
+ credentialSubjectId: "user-1",
+ outcome: "success",
+ });
+ expect(JSON.stringify(events)).not.toContain("user-one-secret");
+ });
+
+ it("returns conflict when concurrent user secret value creation races the unique index", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ const svc = secretService(db);
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ });
+
+ const results = await Promise.allSettled([
+ svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionId: definition.id,
+ value: "first-secret",
+ }),
+ svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionId: definition.id,
+ value: "second-secret",
+ }),
+ ]);
+
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ const rejected = results.find((result) => result.status === "rejected");
+ expect(rejected).toBeTruthy();
+ if (rejected?.status === "rejected") {
+ expect(rejected.reason).toMatchObject({
+ status: 409,
+ message: "User secret value already exists",
+ });
+ }
+
+ const rows = await db
+ .select()
+ .from(companySecrets)
+ .where(eq(companySecrets.userSecretDefinitionId, definition.id));
+ expect(rows.filter((row) => row.ownerUserId === "user-1" && row.status === "active")).toHaveLength(1);
+ });
+
+ it("returns conflict when concurrent user secret definition creation races the unique index", async () => {
+ const companyId = await seedCompany();
+ const svc = secretService(db);
+
+ const results = await Promise.allSettled([
+ svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ }),
+ svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token duplicate",
+ provider: "local_encrypted",
+ }),
+ ]);
+
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ const rejected = results.find((result) => result.status === "rejected");
+ expect(rejected).toBeTruthy();
+ if (rejected?.status === "rejected") {
+ expect(rejected.reason).toMatchObject({
+ status: 409,
+ message: "User secret definition already exists: github_token",
+ });
+ }
+
+ const rows = await db
+ .select()
+ .from(userSecretDefinitions)
+ .where(eq(userSecretDefinitions.companyId, companyId));
+ expect(rows.filter((row) => row.key === "github_token" && row.deletedAt === null)).toHaveLength(1);
+ });
+
+ it("removes user secret values and provider material when deleting a definition", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ await seedCompanyMember(companyId, "user-2", "member");
+ const svc = secretService(db);
+ const awsVault = await svc.createProviderConfig(companyId, {
+ provider: "aws_secrets_manager",
+ displayName: "AWS production",
+ config: { region: "us-east-1", namespace: "prod-use1" },
+ });
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "aws_secrets_manager",
+ providerConfigId: awsVault.id,
+ });
+ let nextVersion = 0;
+ vi.spyOn(awsSecretsManagerProvider, "createSecret").mockImplementation(async (input) => {
+ nextVersion += 1;
+ const externalRef =
+ `arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/${input.context.secretKey}`;
+ return {
+ material: {
+ scheme: "aws_secrets_manager_v1",
+ secretId: externalRef,
+ versionId: `aws-version-${nextVersion}`,
+ source: "managed",
+ },
+ valueSha256: `value-sha-${nextVersion}`,
+ fingerprintSha256: `fingerprint-sha-${nextVersion}`,
+ externalRef,
+ providerVersionRef: `aws-version-${nextVersion}`,
+ };
+ });
+ const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
+ const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionId: definition.id,
+ value: "user-one-secret",
+ });
+ const userTwoSecret = await svc.createCurrentUserSecretValue(companyId, "user-2", {
+ definitionId: definition.id,
+ value: "user-two-secret",
+ });
+
+ const removed = await svc.removeUserSecretDefinition(companyId, definition.id, { userId: "admin-user" });
+ const remainingValues = await db
+ .select()
+ .from(companySecrets)
+ .where(eq(companySecrets.userSecretDefinitionId, definition.id));
+
+ expect(removed).toMatchObject({
+ id: definition.id,
+ key: `github_token__deleted__${definition.id}`,
+ status: "deleted",
+ updatedByUserId: "admin-user",
+ });
+ expect(remainingValues).toHaveLength(0);
+ expect(deleteSpy).toHaveBeenCalledTimes(2);
+ expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
+ externalRef: userOneSecret.externalRef,
+ providerConfig: expect.objectContaining({ id: awsVault.id }),
+ context: {
+ companyId,
+ secretKey: userOneSecret.key,
+ secretName: userOneSecret.name,
+ version: 1,
+ },
+ mode: "delete",
+ }));
+ expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
+ externalRef: userTwoSecret.externalRef,
+ providerConfig: expect.objectContaining({ id: awsVault.id }),
+ context: {
+ companyId,
+ secretKey: userTwoSecret.key,
+ secretName: userTwoSecret.name,
+ version: 1,
+ },
+ mode: "delete",
+ }));
+ });
+
+ it("removes user secret values and provider material when update deletes a definition", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ const svc = secretService(db);
+ const awsVault = await svc.createProviderConfig(companyId, {
+ provider: "aws_secrets_manager",
+ displayName: "AWS production",
+ config: { region: "us-east-1", namespace: "prod-use1" },
+ });
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "aws_secrets_manager",
+ providerConfigId: awsVault.id,
+ });
+ vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
+ material: {
+ scheme: "aws_secrets_manager_v1",
+ secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret",
+ versionId: "aws-version-1",
+ source: "managed",
+ },
+ valueSha256: "value-sha-1",
+ fingerprintSha256: "fingerprint-sha-1",
+ externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret",
+ providerVersionRef: "aws-version-1",
+ });
+ const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
+ const userSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionId: definition.id,
+ value: "user-one-secret",
+ });
+
+ const removed = await svc.updateUserSecretDefinition(
+ companyId,
+ definition.id,
+ { status: "deleted" },
+ { userId: "admin-user" },
+ );
+ const remainingValues = await db
+ .select()
+ .from(companySecrets)
+ .where(eq(companySecrets.userSecretDefinitionId, definition.id));
+
+ expect(removed).toMatchObject({
+ id: definition.id,
+ key: `github_token__deleted__${definition.id}`,
+ status: "deleted",
+ updatedByUserId: "admin-user",
+ });
+ expect(remainingValues).toHaveLength(0);
+ expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
+ externalRef: userSecret.externalRef,
+ providerConfig: expect.objectContaining({ id: awsVault.id }),
+ context: {
+ companyId,
+ secretKey: userSecret.key,
+ secretName: userSecret.name,
+ version: 1,
+ },
+ mode: "delete",
+ }));
+ });
+
+ it("treats nullable user-secret value patches as non-rotation updates", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ const svc = secretService(db);
+ await svc.createUserSecretDefinition(companyId, {
+ key: "github_token",
+ name: "GitHub token",
+ provider: "local_encrypted",
+ });
+ const secret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionKey: "github_token",
+ value: "user-one-secret",
+ });
+
+ const updated = await svc.updateCurrentUserSecretValue(companyId, "user-1", secret.id, {
+ value: null,
+ externalRef: null,
+ providerVersionRef: null,
+ providerConfigId: null,
+ });
+
+ expect(updated.latestVersion).toBe(secret.latestVersion);
+ expect(updated.status).toBe(secret.status);
+ const versions = await db
+ .select()
+ .from(companySecretVersions)
+ .where(eq(companySecretVersions.secretId, secret.id));
+ expect(versions).toHaveLength(1);
+ expect(versions[0]).toMatchObject({ version: secret.latestVersion, status: "current" });
+ expect(versions[0]?.material).toBeTruthy();
+ });
+
+ it("reports missing adapter-config user secret refs before runtime resolution", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ const svc = secretService(db);
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "hermes_api_key",
+ name: "Hermes API key",
+ provider: "local_encrypted",
+ });
+ const adapterConfig = {
+ apiBaseUrl: "http://127.0.0.1:9119/api",
+ apiKey: { type: "user_secret_ref" as const, key: "hermes_api_key", version: "latest" as const },
+ };
+ await svc.syncUserSecretDeclarationsForTarget(companyId, {
+ targetType: "agent",
+ targetId: "agent-1",
+ }, [
+ {
+ definitionKey: "hermes_api_key",
+ configPath: "apiKey",
+ envKey: "apiKey",
+ },
+ ]);
+
+ await expect(
+ svc.collectMissingAdapterConfigRuntimeBindings(
+ companyId,
+ adapterConfig,
+ "hermes_gateway",
+ {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ responsibleUserId: "user-1",
+ },
+ ),
+ ).resolves.toEqual([
+ expect.objectContaining({
+ bindingType: "user_secret_ref",
+ configPath: "apiKey",
+ envKey: "apiKey",
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: "hermes_api_key",
+ responsibleUserId: "user-1",
+ errorCode: "user_secret_missing",
+ }),
+ ]);
+
+ await expect(
+ svc.collectMissingAdapterConfigRuntimeBindings(
+ companyId,
+ {
+ ...adapterConfig,
+ apiKey: {
+ type: "user_secret_ref" as const,
+ key: "hermes_api_key",
+ version: "latest" as const,
+ required: false,
+ },
+ },
+ "hermes_gateway",
+ {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ responsibleUserId: "user-1",
+ },
+ ),
+ ).resolves.toEqual([]);
+
+ await db
+ .update(userSecretDefinitions)
+ .set({ status: "archived" })
+ .where(eq(userSecretDefinitions.id, definition.id));
+ await expect(
+ svc.collectMissingAdapterConfigRuntimeBindings(
+ companyId,
+ adapterConfig,
+ "hermes_gateway",
+ {
+ consumerType: "agent",
+ consumerId: "agent-1",
+ responsibleUserId: "user-1",
+ },
+ ),
+ ).resolves.toEqual([
+ expect.objectContaining({
+ bindingType: "user_secret_ref",
+ configPath: "apiKey",
+ envKey: "apiKey",
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: "hermes_api_key",
+ userSecretDefinitionName: "Hermes API key",
+ responsibleUserId: "user-1",
+ errorCode: "user_secret_definition_inactive",
+ }),
+ ]);
+ });
+
+ it("skips optional user secret refs when the declaration is missing at runtime", async () => {
+ const companyId = await seedCompany();
+ await seedCompanyMember(companyId, "user-1", "owner");
+ const svc = secretService(db);
+ const definition = await svc.createUserSecretDefinition(companyId, {
+ key: "github_api_token",
+ name: "GitHub API token",
+ provider: "local_encrypted",
+ });
+ await svc.createCurrentUserSecretValue(companyId, "user-1", {
+ definitionId: definition.id,
+ value: "ghp_secret",
+ });
+
+ await expect(
+ svc.resolveUserSecretValue(
+ companyId,
+ {
+ definitionKey: "github_api_token",
+ responsibleUserId: "user-1",
+ required: false,
+ },
+ {
+ consumerType: "agent",
+ consumerId: "agent-with-stale-config",
+ configPath: "env.GITHUB_TOKEN",
+ },
+ ),
+ ).resolves.toBeNull();
+
+ await expect(
+ svc.resolveUserSecretValue(
+ companyId,
+ {
+ definitionKey: "github_api_token",
+ responsibleUserId: "user-1",
+ },
+ {
+ consumerType: "agent",
+ consumerId: "agent-with-stale-config",
+ configPath: "env.GITHUB_TOKEN",
+ },
+ ),
+ ).rejects.toMatchObject({
+ details: { code: "binding_missing" },
+ });
+ });
+
it("records stable redacted failure codes for routine env secret resolution", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
diff --git a/server/src/__tests__/setup-supertest.ts b/server/src/__tests__/setup-supertest.ts
index 53fb6472a3..d837215ecb 100644
--- a/server/src/__tests__/setup-supertest.ts
+++ b/server/src/__tests__/setup-supertest.ts
@@ -1,5 +1,8 @@
+import fs from "node:fs";
import { createRequire } from "node:module";
import type { AddressInfo, Server as NetServer } from "node:net";
+import os from "node:os";
+import path from "node:path";
import { Server as TlsServer } from "node:tls";
type SupertestServer = NetServer & {
@@ -21,6 +24,12 @@ type SupertestTestConstructor = {
const require = createRequire(import.meta.url);
const SupertestTest = require("supertest/lib/test.js") as SupertestTestConstructor;
+if (!process.env.CODEX_HOME) {
+ const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vitest-codex-home-"));
+ fs.writeFileSync(path.join(codexHome, "auth.json"), '{"OPENAI_API_KEY":"sk-vitest"}\n', { mode: 0o600 });
+ process.env.CODEX_HOME = codexHome;
+}
+
if (!SupertestTest.prototype.__paperclipLoopbackPatched) {
SupertestTest.prototype.serverAddress = function serverAddress(app, path) {
const addr = app.address();
diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts
index 6ffae2d9d3..7b38119435 100644
--- a/server/src/agent-auth-jwt.ts
+++ b/server/src/agent-auth-jwt.ts
@@ -10,6 +10,7 @@ export interface LocalAgentJwtClaims {
company_id: string;
adapter_type: string;
run_id: string;
+ responsible_user_id?: string | null;
iat: number;
exp: number;
iss?: string;
@@ -88,7 +89,13 @@ function safeCompare(a: string, b: string) {
return timingSafeEqual(left, right);
}
-export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) {
+export function createLocalAgentJwt(
+ agentId: string,
+ companyId: string,
+ adapterType: string,
+ runId: string,
+ responsibleUserId?: string | null,
+) {
const config = jwtConfig();
if (!config) return null;
@@ -98,6 +105,7 @@ export function createLocalAgentJwt(agentId: string, companyId: string, adapterT
company_id: companyId,
adapter_type: adapterType,
run_id: runId,
+ responsible_user_id: responsibleUserId?.trim() || null,
iat: now,
exp: now + config.ttlSeconds,
iss: config.issuer,
@@ -160,6 +168,11 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
const sub = typeof claims.sub === "string" ? claims.sub : null;
const adapterType = typeof claims.adapter_type === "string" ? claims.adapter_type : null;
const runId = typeof claims.run_id === "string" ? claims.run_id : null;
+ const responsibleUserClaim = Object.hasOwn(claims, "responsible_user_id")
+ ? typeof claims.responsible_user_id === "string" && claims.responsible_user_id.trim()
+ ? claims.responsible_user_id.trim()
+ : null
+ : undefined;
const iat = typeof claims.iat === "number" ? claims.iat : null;
const exp = typeof claims.exp === "number" ? claims.exp : null;
if (!sub || !adapterType || !runId || !iat || !exp) return null;
@@ -178,6 +191,7 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
company_id: companyId,
adapter_type: adapterType,
run_id: runId,
+ ...(responsibleUserClaim !== undefined ? { responsible_user_id: responsibleUserClaim } : {}),
iat,
exp,
...(issuer ? { iss: issuer } : {}),
diff --git a/server/src/app.ts b/server/src/app.ts
index 5d415bcfa8..ac63b16326 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -158,6 +158,7 @@ export async function createApp(
},
) {
const app = express();
+ app.locals.paperclipDb = db;
const captureRawBody = (req: express.Request, _res: express.Response, buf: Buffer) => {
(req as unknown as { rawBody: Buffer }).rawBody = buf;
};
diff --git a/server/src/errors.ts b/server/src/errors.ts
index 8ad9b5789e..e502d7e8bc 100644
--- a/server/src/errors.ts
+++ b/server/src/errors.ts
@@ -17,8 +17,8 @@ export function unauthorized(message = "Unauthorized") {
return new HttpError(401, message);
}
-export function forbidden(message = "Forbidden") {
- return new HttpError(403, message);
+export function forbidden(message = "Forbidden", details?: unknown) {
+ return new HttpError(403, message, details);
}
export function notFound(message = "Not found") {
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index 46650a0beb..e174d1319a 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -2,18 +2,136 @@ import { createHash, timingSafeEqual } from "node:crypto";
import type { Request, RequestHandler } from "express";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
-import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db";
+import {
+ activityLog,
+ agentApiKeys,
+ agents,
+ authUsers,
+ companies,
+ companyMemberships,
+ heartbeatRuns,
+ instanceUserRoles,
+} from "@paperclipai/db";
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
-import { normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
+import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { boardAuthService } from "../services/board-auth.js";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
+import { forbidden, unprocessable } from "../errors.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
+function normalizeOptionalString(value: string | null | undefined) {
+ return value?.trim() || null;
+}
+
+async function resolveLegacyRunResponsibleUserId(
+ db: Db,
+ input: { companyId: string; agentId: string; runId: string },
+) {
+ if (!isUuidLike(input.runId)) return null;
+ const run = await db
+ .select({ responsibleUserId: heartbeatRuns.responsibleUserId })
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, input.runId),
+ eq(heartbeatRuns.companyId, input.companyId),
+ eq(heartbeatRuns.agentId, input.agentId),
+ ),
+ )
+ .then((rows) => rows[0] ?? null);
+ return normalizeOptionalString(run?.responsibleUserId);
+}
+
+async function loadResponsibleUserMemberships(
+ db: Db,
+ input: { companyId: string; userId: string | null },
+) {
+ if (!input.userId) return [];
+ const [user, memberships] = await Promise.all([
+ db
+ .select({ id: authUsers.id })
+ .from(authUsers)
+ .where(eq(authUsers.id, input.userId))
+ .then((rows) => rows[0] ?? null),
+ db
+ .select({
+ companyId: companyMemberships.companyId,
+ membershipRole: companyMemberships.membershipRole,
+ status: companyMemberships.status,
+ })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, input.companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.principalId, input.userId),
+ eq(companyMemberships.status, "active"),
+ ),
+ ),
+ ]);
+ return user ? memberships : [];
+}
+
+async function auditAgentJwtRunHeaderMismatch(
+ db: Db,
+ input: { companyId: string; agentId: string; claimRunId: string; headerRunId: string; method: string; url: string },
+) {
+ try {
+ await db.insert(activityLog).values({
+ companyId: input.companyId,
+ actorType: "agent",
+ actorId: input.agentId,
+ action: "auth.agent_jwt_run_header_mismatch",
+ entityType: "heartbeat_run",
+ entityId: input.claimRunId,
+ ...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
+ ...(isUuidLike(input.claimRunId) ? { runId: input.claimRunId } : {}),
+ details: {
+ claimRunId: input.claimRunId,
+ headerRunId: input.headerRunId,
+ method: input.method,
+ url: input.url,
+ },
+ });
+ } catch (err) {
+ logger.warn(
+ { err, companyId: input.companyId, agentId: input.agentId, claimRunId: input.claimRunId },
+ "Failed to audit rejected agent JWT run header mismatch",
+ );
+ }
+}
+
+async function auditAgentKeyMissingResponsibleUser(
+ db: Db,
+ input: { companyId: string; agentId: string; keyId: string; method: string; url: string },
+) {
+ try {
+ await db.insert(activityLog).values({
+ companyId: input.companyId,
+ actorType: "agent",
+ actorId: input.agentId,
+ action: "auth.agent_key_missing_responsible_user",
+ entityType: "agent_api_key",
+ entityId: input.keyId,
+ ...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
+ details: {
+ method: input.method,
+ url: input.url,
+ },
+ });
+ } catch (err) {
+ logger.warn(
+ { err, companyId: input.companyId, agentId: input.agentId, keyId: input.keyId },
+ "Failed to audit rejected agent key without responsible user binding",
+ );
+ }
+}
+
interface ActorMiddlewareOptions {
deploymentMode: DeploymentMode;
resolveSession?: (req: Request) => Promise;
@@ -159,12 +277,46 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
+ const normalizedRunIdHeader = normalizeOptionalString(runIdHeader);
+ if (normalizedRunIdHeader && normalizedRunIdHeader !== claims.run_id) {
+ await auditAgentJwtRunHeaderMismatch(db, {
+ companyId: claims.company_id,
+ agentId: claims.sub,
+ claimRunId: claims.run_id,
+ headerRunId: normalizedRunIdHeader,
+ method: req.method,
+ url: req.originalUrl,
+ });
+ next(
+ unprocessable("X-Paperclip-Run-Id does not match signed agent JWT run_id", {
+ code: "agent_jwt_run_id_mismatch",
+ claimRunId: claims.run_id,
+ headerRunId: normalizedRunIdHeader,
+ }),
+ );
+ return;
+ }
+
+ const onBehalfOfUserId = claims.responsible_user_id !== undefined
+ ? normalizeOptionalString(claims.responsible_user_id)
+ : await resolveLegacyRunResponsibleUserId(db, {
+ companyId: claims.company_id,
+ agentId: claims.sub,
+ runId: claims.run_id,
+ });
+ const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
+ companyId: claims.company_id,
+ userId: onBehalfOfUserId,
+ });
+
req.actor = {
type: "agent",
agentId: claims.sub,
companyId: claims.company_id,
keyId: undefined,
- runId: runIdHeader || claims.run_id || undefined,
+ runId: claims.run_id,
+ onBehalfOfUserId,
+ onBehalfOfMemberships,
source: "agent_jwt",
};
next();
@@ -187,12 +339,32 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
+ const responsibleUserId = normalizeOptionalString(key.responsibleUserId);
+ if (!responsibleUserId) {
+ await auditAgentKeyMissingResponsibleUser(db, {
+ companyId: key.companyId,
+ agentId: key.agentId,
+ keyId: key.id,
+ method: req.method,
+ url: req.originalUrl,
+ });
+ next(forbidden("Responsible user is unavailable for this agent key", {
+ code: "RESPONSIBLE_USER_UNAVAILABLE",
+ }));
+ return;
+ }
+
req.actor = {
type: "agent",
agentId: key.agentId,
companyId: key.companyId,
keyId: key.id,
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
+ onBehalfOfUserId: responsibleUserId,
+ onBehalfOfMemberships: await loadResponsibleUserMemberships(db, {
+ companyId: key.companyId,
+ userId: responsibleUserId,
+ }),
runId: runIdHeader || undefined,
source: "agent_key",
};
diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts
index c9376882f2..afddd0489d 100644
--- a/server/src/middleware/error-handler.ts
+++ b/server/src/middleware/error-handler.ts
@@ -1,9 +1,14 @@
import type { Request, Response, NextFunction } from "express";
+import type { Db } from "@paperclipai/db";
import { ZodError } from "zod";
import { HttpError } from "../errors.js";
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js";
+import { logger } from "./logger.js";
+import {
+ recordResponsibleUserDenialOnActiveRun,
+} from "../services/responsible-user-denial-run-outcomes.js";
export interface ErrorContext {
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
@@ -33,6 +38,36 @@ function attachErrorContext(
}
}
+function getPaperclipDb(req: Request): Db | null {
+ const locals = req.app?.locals as { paperclipDb?: Db; db?: Db } | undefined;
+ return locals?.paperclipDb ?? locals?.db ?? null;
+}
+
+function recordResponsibleUserDenialFromHttpError(
+ req: Request,
+ details: Record | null,
+) {
+ if (req.actor?.type !== "agent") return;
+ const db = getPaperclipDb(req);
+ if (!db) return;
+
+ void recordResponsibleUserDenialOnActiveRun(db, {
+ runId: req.actor.runId ?? null,
+ agentId: req.actor.agentId ?? null,
+ companyId: req.actor.companyId ?? null,
+ code: details?.code,
+ }).catch((recordErr) => {
+ logger.warn(
+ {
+ err: recordErr,
+ runId: req.actor?.runId ?? null,
+ agentId: req.actor?.type === "agent" ? req.actor.agentId ?? null : null,
+ },
+ "failed to record responsible-user denial on heartbeat run",
+ );
+ });
+}
+
export function errorHandler(
err: unknown,
req: Request,
@@ -43,6 +78,7 @@ export function errorHandler(
const details = err.details && typeof err.details === "object" && !Array.isArray(err.details)
? err.details as Record
: null;
+ recordResponsibleUserDenialFromHttpError(req, details);
if (err.status >= 500) {
attachErrorContext(
req,
diff --git a/server/src/redaction.ts b/server/src/redaction.ts
index ebb1e6636a..52b79ba598 100644
--- a/server/src/redaction.ts
+++ b/server/src/redaction.ts
@@ -54,6 +54,7 @@ function sanitizeValue(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) return value.map(sanitizeValue);
if (isSecretRefBinding(value)) return value;
+ if (isUserSecretRefBinding(value)) return value;
if (isPlainBinding(value)) return { type: "plain", value: sanitizeValue(value.value) };
if (!isPlainObject(value)) return value;
return sanitizeRecord(value);
@@ -64,6 +65,11 @@ function isSecretRefBinding(value: unknown): value is { type: "secret_ref"; secr
return value.type === "secret_ref" && typeof value.secretId === "string";
}
+function isUserSecretRefBinding(value: unknown): value is { type: "user_secret_ref"; key: string; version?: unknown } {
+ if (!isPlainObject(value)) return false;
+ return value.type === "user_secret_ref" && typeof value.key === "string";
+}
+
function isPlainBinding(value: unknown): value is { type: "plain"; value: unknown } {
if (!isPlainObject(value)) return false;
return value.type === "plain" && "value" in value;
@@ -101,6 +107,10 @@ export function sanitizeRecord(record: Record): Record item.companyId === companyId && item.status === "active",
+ );
+ if (!membership) {
+ throwOrShadowResponsibleUserCompanyAccessDeny(
+ req,
+ companyId,
+ "RESPONSIBLE_USER_UNAVAILABLE",
+ "Responsible user is unavailable for this company",
+ );
+ return;
+ }
+ const method = typeof req.method === "string" ? req.method.toUpperCase() : "GET";
+ const isSafeMethod = ["GET", "HEAD", "OPTIONS"].includes(method);
+ if (!isSafeMethod && membership.membershipRole === "viewer") {
+ throwOrShadowResponsibleUserCompanyAccessDeny(
+ req,
+ companyId,
+ "RESPONSIBLE_USER_UNAUTHORIZED",
+ "Responsible user is not authorized for write access",
+ );
+ }
+ }
if (req.actor.type === "board" && req.actor.source !== "local_implicit") {
const allowedCompanies = req.actor.companyIds ?? [];
if (!allowedCompanies.includes(companyId)) {
diff --git a/server/src/routes/board-chat.ts b/server/src/routes/board-chat.ts
index 9fc78384f6..b8cab3b459 100644
--- a/server/src/routes/board-chat.ts
+++ b/server/src/routes/board-chat.ts
@@ -147,6 +147,7 @@ export function boardChatRoutes(
const issueSvc = issueService(db);
let issueId = taskId;
+ const actor = getActorInfo(req);
// Find or create the standing "Board Operations" issue that anchors the
// board conversation + decision log.
@@ -170,6 +171,9 @@ export function boardChatRoutes(
// assignee.
status: "todo",
priority: "medium",
+ createdByUserId: actor.actorType === "user" ? actor.actorId : null,
+ responsibleUserId: actor.actorType === "user" ? actor.actorId : null,
+ trustExplicitResponsibleUserId: actor.actorType === "user",
});
issueId = created.id;
}
@@ -180,7 +184,6 @@ export function boardChatRoutes(
// Persist the user's message. Use the authenticated board/user actor so
// attribution and author-type checks pass; "board" (the local fallback)
// is distinct from the "board-concierge" sentinel used for replies.
- const actor = getActorInfo(req);
await issueSvc.addComment(resolvedIssueId, message, {
agentId: actor.agentId ?? undefined,
userId: actor.agentId ? undefined : actor.actorId,
diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts
index 15cc0899d9..3402bf0cf8 100644
--- a/server/src/routes/companies.ts
+++ b/server/src/routes/companies.ts
@@ -375,8 +375,11 @@ export function companyRoutes(db: Db, storage?: StorageService) {
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
throw forbidden("Instance admin required");
}
- const company = await svc.create(req.body);
const ownerPrincipalId = req.actor.userId ?? "local-board";
+ const company = await svc.create({
+ ...req.body,
+ defaultResponsibleUserId: req.body.defaultResponsibleUserId ?? ownerPrincipalId,
+ });
await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active");
await access.ensureRoleDefaultGrants(
company.id,
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 9dc4324a6b..7919b42bc7 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -129,6 +129,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { readAcceptedPlanConfirmationTarget } from "../services/issues.js";
+import { authorizationDeniedDetails } from "../services/authorization.js";
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { redactSensitiveText } from "../redaction.js";
@@ -397,6 +398,89 @@ function readObject(value: unknown): Record {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {};
}
+function hasOwn(record: Record, key: string) {
+ return Object.prototype.hasOwnProperty.call(record, key);
+}
+
+async function auditAgentIssueCreateAttributionSpoof(input: {
+ db: Db;
+ req: Request;
+ companyId: string;
+ entityId?: string | null;
+ surface: string;
+ field: "responsibleUserId" | "createdByUserId";
+ action: "rejected" | "stripped";
+ requestedValue: string | null;
+}) {
+ const actor = getActorInfo(input.req);
+ await logActivity(input.db, {
+ companyId: input.companyId,
+ actorType: actor.actorType,
+ actorId: actor.actorId,
+ agentId: actor.agentId,
+ runId: actor.runId,
+ action: input.action === "rejected"
+ ? "issue.attribution_spoof_rejected"
+ : "issue.attribution_spoof_stripped",
+ entityType: input.entityId ? "issue" : "company",
+ entityId: input.entityId ?? input.companyId,
+ details: {
+ surface: input.surface,
+ field: input.field,
+ requestedValue: input.requestedValue,
+ derivedFrom: "authenticated_actor",
+ },
+ });
+}
+
+async function sanitizeIssueCreateAttribution(
+ db: Db,
+ req: Request,
+ res: Response,
+ companyId: string,
+ input: T,
+ options: { surface: string; entityId?: string | null },
+) {
+ const sanitized = { ...input } as T & Record;
+ if (req.actor.type !== "agent") return sanitized;
+
+ if (hasOwn(sanitized, "responsibleUserId") && sanitized.responsibleUserId != null) {
+ await auditAgentIssueCreateAttributionSpoof({
+ db,
+ req,
+ companyId,
+ entityId: options.entityId,
+ surface: options.surface,
+ field: "responsibleUserId",
+ action: "rejected",
+ requestedValue: readNonEmptyString(sanitized.responsibleUserId),
+ });
+ res.status(422).json({ error: "Agent-created issues cannot set responsibleUserId" });
+ return null;
+ }
+
+ if (hasOwn(sanitized, "createdByUserId") && sanitized.createdByUserId != null) {
+ await auditAgentIssueCreateAttributionSpoof({
+ db,
+ req,
+ companyId,
+ entityId: options.entityId,
+ surface: options.surface,
+ field: "createdByUserId",
+ action: "stripped",
+ requestedValue: readNonEmptyString(sanitized.createdByUserId),
+ });
+ delete sanitized.createdByUserId;
+ }
+
+ delete sanitized.responsibleUserId;
+ return sanitized;
+}
+
+function authenticatedActorResponsibleUserId(req: Request) {
+ return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
+}
+
function readPlanConfirmationTargetForIssue(payload: unknown, issueId: string) {
const target = readObject(readObject(payload).target);
if (target.type !== "issue_document" || target.key !== "plan") return null;
@@ -817,7 +901,7 @@ async function assertCanManageIssueMonitor(
resource: { type: "company", companyId },
});
if (!runtimeDecision.allowed) {
- throw forbidden(runtimeDecision.explanation);
+ throw forbidden(runtimeDecision.explanation, authorizationDeniedDetails(runtimeDecision));
}
if (req.actor.type === "agent" && req.actor.agentId && req.actor.agentId === assigneeAgentId) return;
throw forbidden("Only the assignee agent or a board user can manage issue monitors");
@@ -1974,7 +2058,7 @@ export function issueRoutes(
scope: assignmentScope ?? null,
});
if (decision.allowed) return;
- throw forbidden(decision.explanation);
+ throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
function isTaskBridgeKeyActor(req: Request) {
@@ -5168,7 +5252,11 @@ export function issueRoutes(
assertCompanyAccess(req, companyId);
if (await assertLowTrustControlPlaneDenied(req, res, companyId, null)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
- const { watchdogDiscovery: rawWatchdogDiscovery, ...rawCreateBody } = req.body;
+ const sanitizedBody = await sanitizeIssueCreateAttribution(db, req, res, companyId, req.body, {
+ surface: "issues.create",
+ });
+ if (!sanitizedBody) return;
+ const { watchdogDiscovery: rawWatchdogDiscovery, ...rawCreateBody } = sanitizedBody;
const watchdogDiscovery = normalizeWatchdogDiscovery(rawWatchdogDiscovery);
const watchdogProductBugFollowUp = await resolveTaskWatchdogProductBugFollowUp(
req,
@@ -5278,6 +5366,9 @@ export function issueRoutes(
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
+ actorRunId: actor.runId,
+ actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
+ trustExplicitResponsibleUserId: actor.actorType === "user",
watchdogActorRunId: actor.runId,
});
await issueReferencesSvc.syncIssue(issue.id);
@@ -5394,12 +5485,17 @@ export function issueRoutes(
if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return;
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
+ const sanitizedBody = await sanitizeIssueCreateAttribution(db, req, res, parent.companyId, req.body, {
+ surface: "issues.children.create",
+ entityId: parent.id,
+ });
+ if (!sanitizedBody) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
parent.companyId,
- req.body.assigneeAgentId as string | null | undefined,
+ sanitizedBody.assigneeAgentId as string | null | undefined,
);
const createBody = {
- ...req.body,
+ ...sanitizedBody,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return;
@@ -5410,7 +5506,7 @@ export function issueRoutes(
assigneeUserId: createBody.assigneeUserId ?? null,
};
await assertTaskBridgeCreateAllowed(req, parent.companyId, childAssignmentScope);
- if (req.body.assigneeAgentId || req.body.assigneeUserId) {
+ if (sanitizedBody.assigneeAgentId || sanitizedBody.assigneeUserId) {
await assertCanAssignTasks(req, parent.companyId, childAssignmentScope);
}
await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId);
@@ -5446,6 +5542,9 @@ export function issueRoutes(
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
+ actorRunId: actor.runId,
+ actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
+ trustExplicitResponsibleUserId: actor.actorType === "user",
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
watchdogActorRunId: actor.runId,
@@ -5567,12 +5666,17 @@ export function issueRoutes(
const requestedChildren = [];
for (const child of req.body.children as Array) {
+ const sanitizedChild = await sanitizeIssueCreateAttribution(db, req, res, sourceIssue.companyId, child, {
+ surface: "issues.accepted_plan_decomposition",
+ entityId: sourceIssue.id,
+ });
+ if (!sanitizedChild) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
sourceIssue.companyId,
- child.assigneeAgentId as string | null | undefined,
+ sanitizedChild.assigneeAgentId as string | null | undefined,
);
const childBody = {
- ...child,
+ ...sanitizedChild,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
requestedChildren.push(childBody);
@@ -5611,6 +5715,9 @@ export function issueRoutes(
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
+ actorRunId: actor.runId,
+ actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
+ trustExplicitResponsibleUserId: actor.actorType === "user",
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
});
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 2378afa154..a0a2bacd59 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -52,6 +52,11 @@ import {
createSecretSchema,
updateSecretSchema,
rotateSecretSchema,
+ rotateUserSecretValueSchema,
+ createUserSecretDefinitionSchema,
+ updateUserSecretDefinitionSchema,
+ createUserSecretValueSchema,
+ updateUserSecretValueSchema,
// Approval
createApprovalSchema,
resolveApprovalSchema,
@@ -672,6 +677,16 @@ const BOARD_ONLY_OPERATIONS = new Set([
"DELETE /api/secret-provider-configs/{id}",
"POST /api/secret-provider-configs/{id}/default",
"POST /api/secret-provider-configs/{id}/health",
+ "GET /api/companies/{companyId}/user-secret-definitions",
+ "POST /api/companies/{companyId}/user-secret-definitions",
+ "PATCH /api/companies/{companyId}/user-secret-definitions/{definitionId}",
+ "DELETE /api/companies/{companyId}/user-secret-definitions/{definitionId}",
+ "GET /api/companies/{companyId}/user-secret-definitions/{definitionId}/coverage",
+ "GET /api/companies/{companyId}/me/user-secrets",
+ "POST /api/companies/{companyId}/me/user-secrets",
+ "PATCH /api/companies/{companyId}/me/user-secrets/{secretId}",
+ "POST /api/companies/{companyId}/me/user-secrets/{secretId}/rotate",
+ "DELETE /api/companies/{companyId}/me/user-secrets/{secretId}",
"POST /api/companies/{companyId}/secrets/remote-import",
"POST /api/companies/{companyId}/secrets/remote-import/preview",
"GET /api/secrets/{id}/usage",
@@ -732,6 +747,8 @@ const CREATED_OPERATIONS = new Set([
"POST /api/companies/{companyId}/routines",
"POST /api/routines/{id}/triggers",
"POST /api/companies/{companyId}/secrets",
+ "POST /api/companies/{companyId}/user-secret-definitions",
+ "POST /api/companies/{companyId}/me/user-secrets",
"POST /api/companies/{companyId}/skills",
"POST /api/companies/{companyId}/skills/import",
"POST /api/join-requests/{requestId}/claim-api-key",
@@ -2256,6 +2273,111 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
+registry.registerPath({
+ method: "get",
+ path: "/api/companies/{companyId}/user-secret-definitions",
+ tags: ["secrets"],
+ summary: "List user secret definitions",
+ request: { params: z.object({ companyId: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/api/companies/{companyId}/user-secret-definitions",
+ tags: ["secrets"],
+ summary: "Create a user secret definition",
+ request: {
+ params: z.object({ companyId: z.string() }),
+ body: jsonBody(createUserSecretDefinitionSchema),
+ },
+ responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
+});
+
+registry.registerPath({
+ method: "patch",
+ path: "/api/companies/{companyId}/user-secret-definitions/{definitionId}",
+ tags: ["secrets"],
+ summary: "Update a user secret definition",
+ request: {
+ params: z.object({ companyId: z.string(), definitionId: z.string() }),
+ body: jsonBody(updateUserSecretDefinitionSchema),
+ },
+ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "delete",
+ path: "/api/companies/{companyId}/user-secret-definitions/{definitionId}",
+ tags: ["secrets"],
+ summary: "Delete a user secret definition",
+ request: { params: z.object({ companyId: z.string(), definitionId: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "get",
+ path: "/api/companies/{companyId}/user-secret-definitions/{definitionId}/coverage",
+ tags: ["secrets"],
+ summary: "Get user secret definition coverage",
+ request: { params: z.object({ companyId: z.string(), definitionId: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "get",
+ path: "/api/companies/{companyId}/me/user-secrets",
+ tags: ["secrets"],
+ summary: "List my user secret values",
+ request: { params: z.object({ companyId: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/api/companies/{companyId}/me/user-secrets",
+ tags: ["secrets"],
+ summary: "Create my user secret value",
+ request: {
+ params: z.object({ companyId: z.string() }),
+ body: jsonBody(createUserSecretValueSchema),
+ },
+ responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "patch",
+ path: "/api/companies/{companyId}/me/user-secrets/{secretId}",
+ tags: ["secrets"],
+ summary: "Update my user secret value",
+ request: {
+ params: z.object({ companyId: z.string(), secretId: z.string() }),
+ body: jsonBody(updateUserSecretValueSchema),
+ },
+ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/api/companies/{companyId}/me/user-secrets/{secretId}/rotate",
+ tags: ["secrets"],
+ summary: "Rotate my user secret value",
+ request: {
+ params: z.object({ companyId: z.string(), secretId: z.string() }),
+ body: jsonBody(rotateUserSecretValueSchema),
+ },
+ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "delete",
+ path: "/api/companies/{companyId}/me/user-secrets/{secretId}",
+ tags: ["secrets"],
+ summary: "Delete my user secret value",
+ request: { params: z.object({ companyId: z.string(), secretId: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
// ─── Approvals ───────────────────────────────────────────────────────────────
registry.registerPath({
diff --git a/server/src/routes/pipelines.ts b/server/src/routes/pipelines.ts
index 6f4f2047a8..587a6f7047 100644
--- a/server/src/routes/pipelines.ts
+++ b/server/src/routes/pipelines.ts
@@ -482,7 +482,7 @@ async function assertPipelineWriteAccess(
});
if (!decision.allowed) {
throw new HttpError(403, decision.explanation, {
- code: "pipeline_write_forbidden",
+ code: decision.code ?? "pipeline_write_forbidden",
reason: decision.reason,
pipelineId: input.pipelineId,
});
@@ -900,7 +900,7 @@ export function pipelineRoutes(db: Db, options: Parameters[0], companyId: string) {
+ assertBoard(req);
+ assertCompanyAccess(req, companyId);
+ if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
+ const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
+ if (membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole))) {
+ return;
+ }
+ throw forbidden("Company admin access required");
+}
+
+function currentUserId(req: Parameters[0]) {
+ assertBoard(req);
+ if (req.actor.userId) return req.actor.userId;
+ throw unauthorized("User identity required for user-specific secrets");
+}
+
+function boardActorUser(req: Parameters[0]) {
+ assertBoard(req);
+ return { userId: req.actor.userId ?? null, agentId: null };
+}
+
+function userSecretDefinitionActivityActor(req: Parameters[0]) {
+ assertBoard(req);
+ if (req.actor.userId) {
+ return { actorType: "user" as const, actorId: req.actor.userId };
+ }
+ return { actorType: "system" as const, actorId: req.actor.source ?? "board" };
+}
+
+function isCompanyScopedSecret(secret: { scope?: string | null }) {
+ return (secret.scope ?? "company") === "company";
+}
export function secretRoutes(db: Db) {
const router = Router();
@@ -269,6 +309,291 @@ export function secretRoutes(db: Db) {
res.json(secrets);
});
+ router.get("/companies/:companyId/user-secret-definitions", async (req, res) => {
+ const companyId = req.params.companyId as string;
+ assertSecretDefinitionAdmin(req, companyId);
+ res.json(await svc.listUserSecretDefinitions(companyId));
+ });
+
+ router.post(
+ "/companies/:companyId/user-secret-definitions",
+ validate(createUserSecretDefinitionSchema),
+ async (req, res) => {
+ const companyId = req.params.companyId as string;
+ assertSecretDefinitionAdmin(req, companyId);
+
+ const created = await svc.createUserSecretDefinition(
+ companyId,
+ {
+ key: req.body.key,
+ name: req.body.name,
+ description: req.body.description,
+ status: req.body.status,
+ provider: req.body.provider ?? defaultProvider,
+ providerConfigId: req.body.providerConfigId,
+ managedMode: req.body.managedMode,
+ providerMetadata: req.body.providerMetadata,
+ usageGuidance: req.body.usageGuidance,
+ },
+ boardActorUser(req),
+ );
+ const activityActor = userSecretDefinitionActivityActor(req);
+
+ await logActivity(db, {
+ companyId,
+ actorType: activityActor.actorType,
+ actorId: activityActor.actorId,
+ action: "user_secret_definition.created",
+ entityType: "user_secret_definition",
+ entityId: created.id,
+ details: { key: created.key, provider: created.provider },
+ });
+
+ res.status(201).json(created);
+ },
+ );
+
+ router.patch(
+ "/companies/:companyId/user-secret-definitions/:definitionId",
+ validate(updateUserSecretDefinitionSchema),
+ async (req, res) => {
+ const companyId = req.params.companyId as string;
+ const definitionId = req.params.definitionId as string;
+ assertSecretDefinitionAdmin(req, companyId);
+
+ const updated = await svc.updateUserSecretDefinition(
+ companyId,
+ definitionId,
+ {
+ key: req.body.key,
+ name: req.body.name,
+ description: req.body.description,
+ status: req.body.status,
+ providerConfigId: req.body.providerConfigId,
+ providerMetadata: req.body.providerMetadata,
+ usageGuidance: req.body.usageGuidance,
+ },
+ boardActorUser(req),
+ );
+ if (!updated) {
+ res.status(404).json({ error: "User secret definition not found" });
+ return;
+ }
+ const activityActor = userSecretDefinitionActivityActor(req);
+ const activityAction = req.body.status === "deleted"
+ ? "user_secret_definition.deleted"
+ : "user_secret_definition.updated";
+
+ await logActivity(db, {
+ companyId,
+ actorType: activityActor.actorType,
+ actorId: activityActor.actorId,
+ action: activityAction,
+ entityType: "user_secret_definition",
+ entityId: updated.id,
+ details: { key: updated.key, status: updated.status },
+ });
+
+ res.json(updated);
+ },
+ );
+
+ router.delete("/companies/:companyId/user-secret-definitions/:definitionId", async (req, res) => {
+ const companyId = req.params.companyId as string;
+ const definitionId = req.params.definitionId as string;
+ assertSecretDefinitionAdmin(req, companyId);
+
+ const removed = await svc.removeUserSecretDefinition(
+ companyId,
+ definitionId,
+ boardActorUser(req),
+ );
+ if (!removed) {
+ res.status(404).json({ error: "User secret definition not found" });
+ return;
+ }
+ const activityActor = userSecretDefinitionActivityActor(req);
+
+ await logActivity(db, {
+ companyId,
+ actorType: activityActor.actorType,
+ actorId: activityActor.actorId,
+ action: "user_secret_definition.deleted",
+ entityType: "user_secret_definition",
+ entityId: removed.id,
+ details: { key: removed.key },
+ });
+
+ res.json({ ok: true });
+ });
+
+ router.get("/companies/:companyId/user-secret-definitions/:definitionId/coverage", async (req, res) => {
+ const companyId = req.params.companyId as string;
+ const definitionId = req.params.definitionId as string;
+ assertSecretDefinitionAdmin(req, companyId);
+ res.json(await svc.getUserSecretDefinitionCoverage(companyId, definitionId));
+ });
+
+ router.get("/companies/:companyId/me/user-secrets", async (req, res) => {
+ assertBoard(req);
+ const companyId = req.params.companyId as string;
+ assertCompanyAccess(req, companyId);
+ res.json(await svc.listCurrentUserSecretValues(companyId, currentUserId(req)));
+ });
+
+ router.post(
+ "/companies/:companyId/me/user-secrets",
+ validate(createUserSecretValueSchema),
+ async (req, res) => {
+ assertBoard(req);
+ const companyId = req.params.companyId as string;
+ assertCompanyAccess(req, companyId);
+ const ownerUserId = currentUserId(req);
+ const created = await svc.createCurrentUserSecretValue(
+ companyId,
+ ownerUserId,
+ {
+ definitionKey: req.body.definitionKey,
+ definitionId: req.body.definitionId,
+ value: req.body.value,
+ externalRef: req.body.externalRef,
+ providerVersionRef: req.body.providerVersionRef,
+ providerConfigId: req.body.providerConfigId,
+ },
+ { userId: ownerUserId, agentId: null },
+ );
+
+ await logActivity(db, {
+ companyId,
+ actorType: "user",
+ actorId: ownerUserId,
+ action: "user_secret_value.created",
+ entityType: "secret",
+ entityId: created.id,
+ details: {
+ userSecretDefinitionId: created.userSecretDefinitionId,
+ ownerUserId: created.ownerUserId,
+ provider: created.provider,
+ },
+ });
+
+ res.status(201).json(created);
+ },
+ );
+
+ router.patch(
+ "/companies/:companyId/me/user-secrets/:secretId",
+ validate(updateUserSecretValueSchema),
+ async (req, res) => {
+ assertBoard(req);
+ const companyId = req.params.companyId as string;
+ const secretId = req.params.secretId as string;
+ assertCompanyAccess(req, companyId);
+ const ownerUserId = currentUserId(req);
+ const updated = await svc.updateCurrentUserSecretValue(
+ companyId,
+ ownerUserId,
+ secretId,
+ {
+ status: req.body.status,
+ value: req.body.value,
+ externalRef: req.body.externalRef,
+ providerVersionRef: req.body.providerVersionRef,
+ providerConfigId: req.body.providerConfigId,
+ },
+ { userId: ownerUserId, agentId: null },
+ );
+ if (!updated) {
+ res.status(404).json({ error: "User secret value not found" });
+ return;
+ }
+
+ await logActivity(db, {
+ companyId,
+ actorType: "user",
+ actorId: ownerUserId,
+ action: "user_secret_value.updated",
+ entityType: "secret",
+ entityId: updated.id,
+ details: {
+ userSecretDefinitionId: updated.userSecretDefinitionId,
+ ownerUserId: updated.ownerUserId,
+ status: updated.status,
+ },
+ });
+
+ res.json(updated);
+ },
+ );
+
+ router.post(
+ "/companies/:companyId/me/user-secrets/:secretId/rotate",
+ validate(rotateUserSecretValueSchema),
+ async (req, res) => {
+ assertBoard(req);
+ const companyId = req.params.companyId as string;
+ const secretId = req.params.secretId as string;
+ assertCompanyAccess(req, companyId);
+ const ownerUserId = currentUserId(req);
+ const rotated = await svc.rotateCurrentUserSecretValue(
+ companyId,
+ ownerUserId,
+ secretId,
+ {
+ value: req.body.value,
+ externalRef: req.body.externalRef,
+ providerVersionRef: req.body.providerVersionRef,
+ providerConfigId: req.body.providerConfigId,
+ },
+ { userId: ownerUserId, agentId: null },
+ );
+
+ await logActivity(db, {
+ companyId,
+ actorType: "user",
+ actorId: ownerUserId,
+ action: "user_secret_value.rotated",
+ entityType: "secret",
+ entityId: rotated.id,
+ details: {
+ userSecretDefinitionId: rotated.userSecretDefinitionId,
+ ownerUserId: rotated.ownerUserId,
+ version: rotated.latestVersion,
+ },
+ });
+
+ res.json(rotated);
+ },
+ );
+
+ router.delete("/companies/:companyId/me/user-secrets/:secretId", async (req, res) => {
+ assertBoard(req);
+ const companyId = req.params.companyId as string;
+ const secretId = req.params.secretId as string;
+ assertCompanyAccess(req, companyId);
+ const ownerUserId = currentUserId(req);
+ const removed = await svc.removeCurrentUserSecretValue(companyId, ownerUserId, secretId);
+ if (!removed) {
+ res.status(404).json({ error: "User secret value not found" });
+ return;
+ }
+
+ await logActivity(db, {
+ companyId,
+ actorType: "user",
+ actorId: ownerUserId,
+ action: "user_secret_value.deleted",
+ entityType: "secret",
+ entityId: removed.id,
+ details: {
+ userSecretDefinitionId: removed.userSecretDefinitionId,
+ ownerUserId: removed.ownerUserId,
+ },
+ });
+
+ res.json({ ok: true });
+ });
+
router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@@ -383,6 +708,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
+ if (!isCompanyScopedSecret(existing)) {
+ res.status(404).json({ error: "Secret not found" });
+ return;
+ }
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
@@ -421,6 +750,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
+ if (!isCompanyScopedSecret(existing)) {
+ res.status(404).json({ error: "Secret not found" });
+ return;
+ }
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
@@ -463,6 +796,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
+ if (!isCompanyScopedSecret(existing)) {
+ res.status(404).json({ error: "Secret not found" });
+ return;
+ }
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
@@ -476,6 +813,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
+ if (!isCompanyScopedSecret(existing)) {
+ res.status(404).json({ error: "Secret not found" });
+ return;
+ }
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
@@ -489,6 +830,10 @@ export function secretRoutes(db: Db) {
res.status(404).json({ error: "Secret not found" });
return;
}
+ if (!isCompanyScopedSecret(existing)) {
+ res.status(404).json({ error: "Secret not found" });
+ return;
+ }
assertCompanyAccess(req, existing.companyId);
const removed = await svc.remove(id);
diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts
index 260d501dd3..2985b58d19 100644
--- a/server/src/services/activity.ts
+++ b/server/src/services/activity.ts
@@ -387,6 +387,7 @@ export function activityService(db: Db) {
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
invocationSource: heartbeatRuns.invocationSource,
+ responsibleUserId: heartbeatRuns.responsibleUserId,
errorCode: heartbeatRuns.errorCode,
usageJson: summarizedUsageJson,
resultJson: summarizedResultJson,
diff --git a/server/src/services/agent-secret-bindings.ts b/server/src/services/agent-secret-bindings.ts
index 9e266f21b6..51aa935f4c 100644
--- a/server/src/services/agent-secret-bindings.ts
+++ b/server/src/services/agent-secret-bindings.ts
@@ -18,6 +18,20 @@ interface AgentSecretBindingSyncService {
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
envValue: unknown,
) => Promise;
+ syncUserSecretDeclarationsForTarget?: (
+ companyId: string,
+ target: { targetType: "agent"; targetId: string; pathPrefix?: string },
+ refs: Array<{
+ definitionKey: string;
+ configPath: string;
+ envKey: string;
+ versionSelector?: SecretVersionSelector;
+ required?: boolean;
+ allowMissingOverride?: boolean;
+ label?: string | null;
+ }>,
+ options?: { replaceAll?: boolean },
+ ) => Promise;
}
function asRecord(value: unknown): Record | null {
@@ -67,6 +81,60 @@ function collectSecretRefs(adapterConfig: unknown): Array<{
return refs;
}
+function collectUserSecretRefs(adapterConfig: unknown): Array<{
+ definitionKey: string;
+ configPath: string;
+ envKey: string;
+ versionSelector?: SecretVersionSelector;
+ required?: boolean;
+ allowMissingOverride?: boolean;
+}> {
+ const config = asRecord(adapterConfig);
+ if (!config) return [];
+ const refs: Array<{
+ definitionKey: string;
+ configPath: string;
+ envKey: string;
+ versionSelector?: SecretVersionSelector;
+ required?: boolean;
+ allowMissingOverride?: boolean;
+ }> = [];
+
+ const envValue = asRecord(config.env);
+ for (const [key, rawBinding] of Object.entries(envValue ?? {})) {
+ const parsed = envBindingSchema.safeParse(rawBinding);
+ if (!parsed.success) continue;
+ const binding = parsed.data;
+ if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue;
+ refs.push({
+ definitionKey: binding.key,
+ configPath: `env.${key}`,
+ envKey: key,
+ versionSelector: binding.version ?? "latest",
+ required: binding.required ?? true,
+ allowMissingOverride: binding.allowMissingOverride ?? false,
+ });
+ }
+
+ for (const [key, rawBinding] of Object.entries(config)) {
+ if (key === "env") continue;
+ const parsed = envBindingSchema.safeParse(rawBinding);
+ if (!parsed.success) continue;
+ const binding = parsed.data;
+ if (typeof binding !== "object" || binding === null || binding.type !== "user_secret_ref") continue;
+ refs.push({
+ definitionKey: binding.key,
+ configPath: key,
+ envKey: key,
+ versionSelector: binding.version ?? "latest",
+ required: binding.required ?? true,
+ allowMissingOverride: binding.allowMissingOverride ?? false,
+ });
+ }
+
+ return refs;
+}
+
export async function syncAgentAdapterEnvBindings(input: {
secretsSvc: AgentSecretBindingSyncService;
companyId: string;
@@ -80,6 +148,12 @@ export async function syncAgentAdapterEnvBindings(input: {
collectSecretRefs(input.adapterConfig),
{ replaceAll: true },
);
+ await input.secretsSvc.syncUserSecretDeclarationsForTarget?.(
+ input.companyId,
+ { targetType: "agent", targetId: input.agentId },
+ collectUserSecretRefs(input.adapterConfig),
+ { replaceAll: true },
+ );
return;
}
const envValue = asRecord(asRecord(input.adapterConfig)?.env);
diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts
index 9be0140503..ac98371573 100644
--- a/server/src/services/agents.ts
+++ b/server/src/services/agents.ts
@@ -758,7 +758,12 @@ export function agentService(db: Db) {
});
},
- createApiKey: async (id: string, name: string, scope: AgentApiKeyScope = { kind: "standard" }) => {
+ createApiKey: async (
+ id: string,
+ name: string,
+ scope: AgentApiKeyScope = { kind: "standard" },
+ options?: { responsibleUserId?: string | null },
+ ) => {
const existing = await getById(id);
if (!existing) throw notFound("Agent not found");
if (existing.status === "pending_approval") {
@@ -777,6 +782,7 @@ export function agentService(db: Db) {
companyId: existing.companyId,
name,
keyHash,
+ responsibleUserId: options?.responsibleUserId?.trim() || null,
scopeConfig: scope.kind === "standard" ? null : scope,
})
.returning()
@@ -786,6 +792,7 @@ export function agentService(db: Db) {
id: created.id,
name: created.name,
scope: normalizeAgentApiKeyScope(created.scopeConfig),
+ responsibleUserId: created.responsibleUserId,
token,
createdAt: created.createdAt,
};
@@ -796,6 +803,7 @@ export function agentService(db: Db) {
.select({
id: agentApiKeys.id,
name: agentApiKeys.name,
+ responsibleUserId: agentApiKeys.responsibleUserId,
scopeConfig: agentApiKeys.scopeConfig,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
@@ -806,6 +814,7 @@ export function agentService(db: Db) {
id: row.id,
name: row.name,
scope: normalizeAgentApiKeyScope(row.scopeConfig),
+ responsibleUserId: row.responsibleUserId,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
}))),
@@ -817,6 +826,7 @@ export function agentService(db: Db) {
agentId: agentApiKeys.agentId,
companyId: agentApiKeys.companyId,
name: agentApiKeys.name,
+ responsibleUserId: agentApiKeys.responsibleUserId,
scopeConfig: agentApiKeys.scopeConfig,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts
index d3c2a2749c..dccd0ea6c1 100644
--- a/server/src/services/authorization.ts
+++ b/server/src/services/authorization.ts
@@ -2,6 +2,7 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
+ authUsers,
companyMemberships,
heartbeatRuns,
instanceUserRoles,
@@ -26,12 +27,15 @@ export type AuthorizationActor =
userId?: string | null;
companyIds?: string[];
memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
+ onBehalfOfMemberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
isInstanceAdmin?: boolean;
+ ignoreInstanceAdmin?: boolean;
agentId?: string | null;
companyId?: string | null;
keyId?: string | null;
keyScope?: AgentApiKeyScope | null;
runId?: string | null;
+ onBehalfOfUserId?: string | null;
source?:
| "local_implicit"
| "session"
@@ -77,6 +81,7 @@ export type AuthorizationDecision = {
allowed: boolean;
action: AuthorizationAction;
explanation: string;
+ code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE";
reason:
| "allow_low_trust_boundary"
| "allow_local_board"
@@ -407,6 +412,61 @@ function deny(input: Omit): AuthorizationDecis
return { ...input, allowed: false };
}
+type ResponsibleUserSnapshot = {
+ userId: string;
+ companyId: string;
+ userExists: boolean;
+ activeMembership: { companyId: string; membershipRole?: string | null; status?: string } | null;
+};
+
+type ResponsibleUserActorWithMemo = AuthorizationActor & {
+ __responsibleUserSnapshotMemo?: Map>;
+};
+
+const responsibleUserSnapshotCache = new Map<
+ string,
+ { expiresAt: number; promise: Promise }
+>();
+
+function responsibleUserSnapshotTtlMs() {
+ const raw = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS?.trim();
+ if (!raw) return 5_000;
+ const parsed = Number(raw);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000;
+}
+
+export function responsibleUserAuthzShadowMode() {
+ const mode = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE?.trim().toLowerCase();
+ const shadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW?.trim().toLowerCase();
+ return mode === "shadow" || shadow === "1" || shadow === "true" || shadow === "yes";
+}
+
+function activeActorMembership(
+ memberships: Array<{ companyId: string; membershipRole?: string | null; status?: string }> | null | undefined,
+ companyId: string,
+) {
+ return memberships?.find((membership) => membership.companyId === companyId && membership.status === "active") ?? null;
+}
+
+function activeResponsibleUserCanAuthorizeIssueAction(
+ action: AuthorizationAction,
+ membership: ResponsibleUserSnapshot["activeMembership"],
+) {
+ return Boolean(
+ membership &&
+ membership.status === "active" &&
+ membership.membershipRole !== "viewer" &&
+ (action === "issue:comment" || action === "issue:mutate")
+ );
+}
+
+export function authorizationDeniedDetails(decision: AuthorizationDecision) {
+ return {
+ ...(decision.code ? { code: decision.code } : {}),
+ reason: decision.reason,
+ };
+}
+
export function authorizationService(db: Db) {
async function isInstanceAdmin(userId: string | null | undefined): Promise {
if (!userId) return false;
@@ -441,6 +501,82 @@ export function authorizationService(db: Db) {
.then((rows) => rows[0] ?? null);
}
+ async function loadResponsibleUserSnapshot(companyId: string, userId: string): Promise {
+ const [user, membership] = await Promise.all([
+ db
+ .select({ id: authUsers.id })
+ .from(authUsers)
+ .where(eq(authUsers.id, userId))
+ .then((rows) => rows[0] ?? null),
+ db
+ .select({
+ companyId: companyMemberships.companyId,
+ membershipRole: companyMemberships.membershipRole,
+ status: companyMemberships.status,
+ })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.principalId, userId),
+ eq(companyMemberships.status, "active"),
+ ),
+ )
+ .then((rows) => rows[0] ?? null),
+ ]);
+ return {
+ userId,
+ companyId,
+ userExists: Boolean(user),
+ activeMembership: user ? membership : null,
+ };
+ }
+
+ function getResponsibleUserSnapshot(input: {
+ actor: AuthorizationActor;
+ companyId: string;
+ userId: string;
+ }): Promise {
+ const actorWithMemo = input.actor as ResponsibleUserActorWithMemo;
+ const key = `${input.companyId}:${input.userId}`;
+ actorWithMemo.__responsibleUserSnapshotMemo ??= new Map();
+ const requestMemo = actorWithMemo.__responsibleUserSnapshotMemo.get(key);
+ if (requestMemo) return requestMemo;
+
+ const actorMembership = activeActorMembership(input.actor.onBehalfOfMemberships, input.companyId);
+ if (actorMembership) {
+ const promise = Promise.resolve({
+ userId: input.userId,
+ companyId: input.companyId,
+ userExists: true,
+ activeMembership: actorMembership,
+ });
+ actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise);
+ return promise;
+ }
+
+ const now = Date.now();
+ const cached = responsibleUserSnapshotCache.get(key);
+ if (cached && cached.expiresAt > now) {
+ actorWithMemo.__responsibleUserSnapshotMemo.set(key, cached.promise);
+ return cached.promise;
+ }
+
+ const ttlMs = responsibleUserSnapshotTtlMs();
+ const promise = loadResponsibleUserSnapshot(input.companyId, input.userId);
+ if (ttlMs > 0) {
+ responsibleUserSnapshotCache.set(key, { expiresAt: now + ttlMs, promise });
+ promise.catch(() => {
+ if (responsibleUserSnapshotCache.get(key)?.promise === promise) {
+ responsibleUserSnapshotCache.delete(key);
+ }
+ });
+ }
+ actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise);
+ return promise;
+ }
+
async function findGrant(
companyId: string,
principalType: PrincipalType,
@@ -1087,7 +1223,7 @@ export function authorizationService(db: Db) {
});
}
- async function decide(input: {
+ async function decideBase(input: {
actor: AuthorizationActor;
action: AuthorizationAction;
resource: AuthorizationResource;
@@ -1164,6 +1300,7 @@ export function authorizationService(db: Db) {
// elevated — not even via stale instance_admin rows left behind by
// deployments that ran the pre-hardening cloud_tenant path.
if (
+ !input.actor.ignoreInstanceAdmin &&
input.actor.source !== "cloud_tenant" &&
(input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId))
) {
@@ -1504,6 +1641,95 @@ export function authorizationService(db: Db) {
});
}
+ async function applyResponsibleUserIntersection(
+ input: {
+ actor: AuthorizationActor;
+ action: AuthorizationAction;
+ resource: AuthorizationResource;
+ scope?: Record | null;
+ },
+ agentDecision: AuthorizationDecision,
+ ): Promise {
+ const responsibleUserId = input.actor.onBehalfOfUserId?.trim();
+ if (input.actor.type !== "agent" || !responsibleUserId || !agentDecision.allowed) {
+ return agentDecision;
+ }
+
+ const companyId = companyIdForResource(input.resource);
+ const snapshot = await getResponsibleUserSnapshot({
+ actor: input.actor,
+ companyId,
+ userId: responsibleUserId,
+ });
+ const denyCode: AuthorizationDecision["code"] =
+ snapshot.userExists && snapshot.activeMembership
+ ? "RESPONSIBLE_USER_UNAUTHORIZED"
+ : "RESPONSIBLE_USER_UNAVAILABLE";
+
+ const userDecision = snapshot.userExists && snapshot.activeMembership
+ ? await decideBase({
+ ...input,
+ actor: {
+ type: "board",
+ userId: responsibleUserId,
+ companyIds: [companyId],
+ memberships: [snapshot.activeMembership],
+ isInstanceAdmin: false,
+ ignoreInstanceAdmin: true,
+ source: "session",
+ },
+ })
+ : deny({
+ action: input.action,
+ reason: "deny_missing_membership",
+ explanation: `Responsible user ${responsibleUserId} is unavailable for company ${companyId}.`,
+ });
+
+ if (
+ !userDecision.allowed &&
+ userDecision.reason === "deny_unsupported_action" &&
+ activeResponsibleUserCanAuthorizeIssueAction(input.action, snapshot.activeMembership)
+ ) {
+ return agentDecision;
+ }
+
+ if (userDecision.allowed) return agentDecision;
+
+ const denied = deny({
+ action: input.action,
+ reason: userDecision.reason,
+ code: denyCode,
+ explanation:
+ denyCode === "RESPONSIBLE_USER_UNAVAILABLE"
+ ? `Responsible user ${responsibleUserId} is unavailable for company ${companyId}.`
+ : `Responsible user ${responsibleUserId} is not authorized for ${input.action}: ${userDecision.explanation}`,
+ grant: userDecision.grant,
+ });
+
+ logger.warn({
+ authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce",
+ code: denied.code,
+ reason: userDecision.reason,
+ action: input.action,
+ resourceType: input.resource.type,
+ companyId,
+ actorAgentId: input.actor.agentId ?? null,
+ responsibleUserId,
+ }, "responsible-user authorization intersection denied");
+
+ return responsibleUserAuthzShadowMode() ? agentDecision : denied;
+ }
+
+ async function decide(input: {
+ actor: AuthorizationActor;
+ action: AuthorizationAction;
+ resource: AuthorizationResource;
+ scope?: Record | null;
+ }): Promise {
+ const agentDecision = await decideBase(input);
+ return applyResponsibleUserIntersection(input, agentDecision);
+ }
+
return {
decide,
decidePrincipalGrant,
diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts
index 92ac956248..f33f7187a5 100644
--- a/server/src/services/companies.ts
+++ b/server/src/services/companies.ts
@@ -131,6 +131,7 @@ export function companyService(db: Db) {
budgetMonthlyCents: companies.budgetMonthlyCents,
spentMonthlyCents: companies.spentMonthlyCents,
attachmentMaxBytes: companies.attachmentMaxBytes,
+ defaultResponsibleUserId: companies.defaultResponsibleUserId,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 86803fd893..6b5282801c 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -32,6 +32,7 @@ import {
agentWakeupRequests,
activityLog,
approvals,
+ companyMemberships,
companySkills as companySkillsTable,
companies,
costEvents,
@@ -58,6 +59,7 @@ import {
import { conflict, HttpError, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { publishLiveEvent } from "./live-events.js";
+import { normalizeResponsibleUserDenialCode } from "./responsible-user-denial-run-outcomes.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
import { getServerAdapter, listAdapterModelProfiles, runningProcesses } from "../adapters/index.js";
import type {
@@ -200,6 +202,7 @@ import {
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared";
+import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-local/server";
import { environmentService } from "./environments.js";
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
import { environmentRuntimeService } from "./environment-runtime.js";
@@ -426,6 +429,22 @@ const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set([
"approval_approved",
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
]);
+const ISSUE_RESPONSIBLE_USER_WAKE_REASONS = new Set([
+ "issue_assigned",
+ "issue_checked_out",
+ "issue_commented",
+ "issue_comment_mentioned",
+ "issue_reopened_via_comment",
+ "issue_blockers_resolved",
+ "issue_children_completed",
+ "issue_status_changed",
+ "issue_tree_restored",
+ "issue_recovery_action_restored",
+ "execution_review_requested",
+ "execution_approval_requested",
+ "execution_changes_requested",
+ "approval_approved",
+]);
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
@@ -449,9 +468,19 @@ type RuntimeConfigSecretResolver = Pick<
>;
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
+ if (missing.bindingType === "user_secret_ref") {
+ const definitionLabel =
+ missing.userSecretDefinitionName
+ ? `"${missing.userSecretDefinitionName}"`
+ : missing.userSecretDefinitionKey
+ ? `"${missing.userSecretDefinitionKey}"`
+ : "declared user secret";
+ const ownerLabel = missing.responsibleUserId ? ` for responsible user ${missing.responsibleUserId}` : "";
+ return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`;
+ }
const secretLabel = missing.secretName
? `"${missing.secretName}"`
- : missing.secretId;
+ : missing.secretId ?? "unknown";
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
}
@@ -530,6 +559,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
adapterType?: string | null;
issueId?: string | null;
heartbeatRunId?: string | null;
+ responsibleUserId?: string | null;
environmentId?: string | null;
environmentEnv?: unknown;
projectId?: string | null;
@@ -596,7 +626,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
environmentEnv,
- { consumerType: "environment", consumerId: input.environmentId },
+ {
+ consumerType: "environment",
+ consumerId: input.environmentId,
+ responsibleUserId: input.responsibleUserId ?? null,
+ },
)),
);
}
@@ -605,7 +639,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
parseObject(executionRunConfig.env),
- { consumerType: "agent", consumerId: input.agentId },
+ {
+ consumerType: "agent",
+ consumerId: input.agentId,
+ responsibleUserId: input.responsibleUserId ?? null,
+ },
)),
);
if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") {
@@ -614,7 +652,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
input.companyId,
executionRunConfig,
input.adapterType ?? null,
- { consumerType: "agent", consumerId: input.agentId },
+ {
+ consumerType: "agent",
+ consumerId: input.agentId,
+ responsibleUserId: input.responsibleUserId ?? null,
+ },
)),
);
}
@@ -624,7 +666,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
projectEnv,
- { consumerType: "project", consumerId: input.projectId },
+ {
+ consumerType: "project",
+ consumerId: input.projectId,
+ responsibleUserId: input.responsibleUserId ?? null,
+ },
)),
);
}
@@ -633,7 +679,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
...(await input.secretsSvc.collectMissingRuntimeBindings(
input.companyId,
routineEnv,
- { consumerType: "routine", consumerId: input.routineId },
+ {
+ consumerType: "routine",
+ consumerId: input.routineId,
+ responsibleUserId: input.responsibleUserId ?? null,
+ },
)),
);
}
@@ -689,6 +739,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.environmentId,
actorType: "agent",
actorId: input.agentId ?? null,
+ responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -705,6 +756,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.agentId,
actorType: "agent",
actorId: input.agentId,
+ responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -731,6 +783,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.projectId,
actorType: "agent",
actorId: input.agentId ?? null,
+ responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -757,6 +810,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
consumerId: input.routineId,
actorType: "agent",
actorId: input.agentId ?? null,
+ responsibleUserId: input.responsibleUserId ?? null,
issueId: input.issueId ?? null,
heartbeatRunId: input.heartbeatRunId ?? null,
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
@@ -773,6 +827,45 @@ export async function resolveExecutionRunAdapterConfig(input: {
secretKeys.add(key);
}
}
+ // Pre-dispatch credential gate for codex_local: a managed Codex home with no
+ // usable auth.json and an empty OPENAI_API_KEY would dispatch a run that
+ // immediately fails with "no Codex credentials provisioned" (adapter_failed),
+ // making a configuration problem look like a runtime failure. Surface it as a
+ // configuration-incomplete blocker instead, naming the missing credential
+ // action and owner without leaking any secret value. This runs after secret
+ // resolution so a per-agent OPENAI_API_KEY (plain or resolved secret) counts
+ // as satisfying the credential. It shares the exact readiness predicate the
+ // adapter uses at execute time, so the two cannot drift.
+ if ((input.adapterType ?? null) === "codex_local") {
+ const resolvedEnv = parseObject(resolvedConfig.env);
+ const readiness = await evaluateCodexCredentialReadiness({
+ env: process.env,
+ companyId: input.companyId,
+ configuredCodexHome: readNonEmptyString(resolvedEnv.CODEX_HOME),
+ configuredApiKey: readNonEmptyString(resolvedEnv.OPENAI_API_KEY),
+ });
+ if (readiness.managed && !readiness.ready) {
+ throw new ConfigurationIncompleteFailure(
+ `configuration incomplete: no Codex credentials available for managed home "${readiness.effectiveHome}". ` +
+ `Sign in to Codex on the host with a ChatGPT subscription, or bind a per-agent OPENAI_API_KEY secret for this agent.`,
+ {
+ configurationIncomplete: {
+ reason: "codex_credentials_missing",
+ companyId: input.companyId,
+ agentId: input.agentId ?? null,
+ issueId: input.issueId ?? null,
+ projectId: input.projectId ?? null,
+ routineId: input.routineId ?? null,
+ responsibleUserId: input.responsibleUserId ?? null,
+ adapterType: "codex_local",
+ requiredEnvKeys: ["OPENAI_API_KEY"],
+ effectiveCodexHome: readiness.effectiveHome,
+ missingBindings: [],
+ },
+ },
+ );
+ }
+ }
return {
resolvedConfig,
secretKeys,
@@ -4890,6 +4983,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionPolicy: issues.executionPolicy,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
+ parentId: issues.parentId,
+ createdByUserId: issues.createdByUserId,
+ responsibleUserId: issues.responsibleUserId,
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
@@ -4905,13 +5001,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
issueContext: Awaited> | null,
) {
if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) {
- return { routineId: null, env: null };
+ return { routineId: null, env: null, responsibleUserId: null };
}
const routineRun = issueContext.originRunId
? await db
.select({
routineRevisionId: routineRuns.routineRevisionId,
+ responsibleUserId: routineRuns.responsibleUserId,
})
.from(routineRuns)
.where(
@@ -4928,6 +5025,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const revision = await db
.select({
snapshot: routineRevisions.snapshot,
+ responsibleUserId: routineRevisions.responsibleUserId,
})
.from(routineRevisions)
.where(
@@ -4940,16 +5038,161 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.then((rows) => rows[0] ?? null);
const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined;
if (snapshot?.version === 1) {
- return { routineId: issueContext.originId, env: snapshot.routine.env ?? null };
+ return {
+ routineId: issueContext.originId,
+ env: snapshot.routine.env ?? null,
+ responsibleUserId: revision?.responsibleUserId ?? snapshot.routine.responsibleUserId ?? null,
+ };
}
}
const routine = await db
- .select({ env: routines.env })
+ .select({ env: routines.env, responsibleUserId: routines.responsibleUserId })
.from(routines)
.where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId)))
.then((rows) => rows[0] ?? null);
- return { routineId: issueContext.originId, env: routine?.env ?? null };
+ return {
+ routineId: issueContext.originId,
+ env: routine?.env ?? null,
+ responsibleUserId: routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null,
+ };
+ }
+
+ async function resolveCompanyDefaultResponsibleUserId(companyId: string) {
+ const company = await db
+ .select({ defaultResponsibleUserId: companies.defaultResponsibleUserId })
+ .from(companies)
+ .where(eq(companies.id, companyId))
+ .then((rows) => rows[0] ?? null);
+ const explicitDefault = readNonEmptyString(company?.defaultResponsibleUserId);
+ if (explicitDefault) return explicitDefault;
+
+ const owner = await db
+ .select({ userId: companyMemberships.principalId })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.status, "active"),
+ eq(companyMemberships.membershipRole, "owner"),
+ ),
+ )
+ .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id))
+ .limit(1)
+ .then((rows) => rows[0] ?? null);
+ if (owner?.userId) return owner.userId;
+
+ const firstUser = await db
+ .select({ userId: companyMemberships.principalId })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.status, "active"),
+ ),
+ )
+ .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id))
+ .limit(1)
+ .then((rows) => rows[0] ?? null);
+ return firstUser?.userId ?? null;
+ }
+
+ async function resolveParentIssueResponsibleUserId(companyId: string, parentId: string | null | undefined) {
+ if (!parentId) return null;
+ const parent = await db
+ .select({
+ responsibleUserId: issues.responsibleUserId,
+ createdByUserId: issues.createdByUserId,
+ })
+ .from(issues)
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, parentId)))
+ .then((rows) => rows[0] ?? null);
+ return parent?.responsibleUserId ?? null;
+ }
+
+ function isManualUserRun(input: {
+ contextSnapshot: Record;
+ requestedByActorType?: "user" | "agent" | "system" | null;
+ source?: WakeupOptions["source"] | null;
+ triggerDetail?: WakeupOptions["triggerDetail"] | null;
+ }) {
+ if (input.requestedByActorType !== "user") return false;
+ const wakeReason = readNonEmptyString(input.contextSnapshot.wakeReason);
+ if (wakeReason && ISSUE_RESPONSIBLE_USER_WAKE_REASONS.has(wakeReason)) return false;
+ return input.source === "on_demand" || input.triggerDetail === "manual";
+ }
+
+ async function resolveResponsibleUserIdForRunSeed(input: {
+ companyId: string;
+ contextSnapshot: Record;
+ issueContext: Awaited> | null;
+ routineEnvContext: Awaited>;
+ requestedByActorType?: "user" | "agent" | "system" | null;
+ requestedByActorId?: string | null;
+ source?: WakeupOptions["source"] | null;
+ triggerDetail?: WakeupOptions["triggerDetail"] | null;
+ existingRunResponsibleUserId?: string | null;
+ }) {
+ const contextResponsibleUserId = readNonEmptyString(input.contextSnapshot.responsibleUserId);
+ const requestedUserId = input.requestedByActorType === "user"
+ ? readNonEmptyString(input.requestedByActorId)
+ : null;
+ if (contextResponsibleUserId) return contextResponsibleUserId;
+ if (input.existingRunResponsibleUserId) return input.existingRunResponsibleUserId;
+ if (input.routineEnvContext.responsibleUserId) return input.routineEnvContext.responsibleUserId;
+ if (isManualUserRun(input) && requestedUserId) return requestedUserId;
+ if (input.issueContext?.responsibleUserId) return input.issueContext.responsibleUserId;
+ const parentResponsibleUserId = await resolveParentIssueResponsibleUserId(input.companyId, input.issueContext?.parentId);
+ if (parentResponsibleUserId) return parentResponsibleUserId;
+ if (input.issueContext) return resolveCompanyDefaultResponsibleUserId(input.companyId);
+ if (requestedUserId) return requestedUserId;
+ return resolveCompanyDefaultResponsibleUserId(input.companyId);
+ }
+
+ async function resolveResponsibleUserIdForRun(input: {
+ run: typeof heartbeatRuns.$inferSelect;
+ contextSnapshot: Record;
+ issueContext: Awaited> | null;
+ routineEnvContext: Awaited>;
+ }) {
+ const responsibleUserId = await resolveResponsibleUserIdForRunSeed({
+ companyId: input.run.companyId,
+ contextSnapshot: input.contextSnapshot,
+ issueContext: input.issueContext,
+ routineEnvContext: input.routineEnvContext,
+ existingRunResponsibleUserId: input.run.responsibleUserId,
+ source: input.run.invocationSource as WakeupOptions["source"],
+ triggerDetail: input.run.triggerDetail as WakeupOptions["triggerDetail"],
+ });
+ if (!responsibleUserId) {
+ throw new HttpError(422, "Unable to resolve responsible user for heartbeat run dispatch", {
+ code: "responsible_user_unresolved",
+ runId: input.run.id,
+ agentId: input.run.agentId,
+ companyId: input.run.companyId,
+ issueId: input.issueContext?.id ?? null,
+ invocationSource: input.run.invocationSource,
+ triggerDetail: input.run.triggerDetail,
+ wakeReason: readNonEmptyString(input.contextSnapshot.wakeReason),
+ });
+ }
+ return responsibleUserId;
+ }
+
+ async function resolveResponsibleUserIdForRunContext(
+ run: typeof heartbeatRuns.$inferSelect,
+ contextSnapshot: Record,
+ ) {
+ const issueId = readNonEmptyString(contextSnapshot.issueId) ?? readNonEmptyString(contextSnapshot.taskId);
+ const issueContext = issueId ? await getIssueExecutionContext(run.companyId, issueId) : null;
+ return resolveResponsibleUserIdForRun({
+ run,
+ contextSnapshot,
+ issueContext,
+ routineEnvContext: await getRoutineEnvForExecutionIssue(run.companyId, issueContext),
+ });
}
async function getRuntimeState(agentId: string) {
@@ -7065,6 +7308,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
retryReason: "missing_issue_comment",
missingIssueCommentForRunId: run.id,
}, "status_only");
+ const responsibleUserId = await resolveResponsibleUserIdForRunContext(run, retryContextSnapshot);
const now = new Date();
const retryRun = await db.transaction(async (tx) => {
@@ -7110,6 +7354,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: retryContextSnapshot,
+ responsibleUserId,
sessionIdBefore: sessionBefore,
retryOfRunId: run.id,
issueCommentStatus: "not_applicable",
@@ -7302,6 +7547,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
wakeReason: "process_lost_retry",
retryReason: "process_lost",
}, "normal_model");
+ const responsibleUserId = await resolveResponsibleUserIdForRunContext(run, retryContextSnapshot);
const queued = await db.transaction(async (tx) => {
const wakeupRequest = await tx
@@ -7334,6 +7580,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: retryContextSnapshot,
+ responsibleUserId,
sessionIdBefore: sessionBefore,
retryOfRunId: run.id,
processLossRetryCount: (run.processLossRetryCount ?? 0) + 1,
@@ -7900,6 +8147,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
}, "normal_model");
+ const responsibleUserId = await resolveResponsibleUserIdForRunContext(run, retryContextSnapshot);
const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}`
: null;
@@ -8089,6 +8337,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
status: "scheduled_retry",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: retryContextSnapshot,
+ responsibleUserId,
sessionIdBefore: sessionBefore,
retryOfRunId: run.id,
scheduledRetryAt: schedule.dueAt,
@@ -8720,10 +8969,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
const claimedAt = new Date();
+ const responsibleUserId = await resolveResponsibleUserIdForRun({
+ run,
+ contextSnapshot: context,
+ issueContext: issueId ? await getIssueExecutionContext(run.companyId, issueId) : null,
+ routineEnvContext: { routineId: null, env: null, responsibleUserId: null },
+ });
const claimed = await db
.update(heartbeatRuns)
.set({
status: "running",
+ responsibleUserId,
startedAt: run.startedAt ?? claimedAt,
updatedAt: claimedAt,
})
@@ -9814,6 +10070,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
delete context.acceptedPlanWakeRouting;
}
const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext);
+ const responsibleUserId = await resolveResponsibleUserIdForRun({
+ run,
+ contextSnapshot: context,
+ issueContext,
+ routineEnvContext,
+ });
+ if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
+ await db
+ .update(heartbeatRuns)
+ .set({ responsibleUserId, updatedAt: new Date() })
+ .where(eq(heartbeatRuns.id, run.id));
+ run = { ...run, responsibleUserId };
+ }
+ if (responsibleUserId && issueContext && !issueContext.responsibleUserId) {
+ await db
+ .update(issues)
+ .set({ responsibleUserId, updatedAt: new Date() })
+ .where(and(eq(issues.companyId, agent.companyId), eq(issues.id, issueContext.id), isNull(issues.responsibleUserId)));
+ issueContext = { ...issueContext, responsibleUserId };
+ }
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
@@ -10126,6 +10402,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
projectId: projectContext?.id ?? null,
routineId: routineEnvContext.routineId,
+ responsibleUserId,
executionRunConfig,
projectEnv: projectContext?.env ?? null,
routineEnv: routineEnvContext.env,
@@ -11123,7 +11400,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const adapter = getServerAdapter(agent.adapterType);
const authToken = adapter.supportsLocalAgentJwt
- ? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id)
+ ? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id, run.responsibleUserId)
: null;
if (adapter.supportsLocalAgentJwt && !authToken) {
logger.warn(
@@ -11441,13 +11718,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
);
+ const recordedResponsibleUserDenialCode =
+ normalizeResponsibleUserDenialCode(latestRun?.errorCode);
const runErrorCode =
outcome === "timed_out"
? "timeout"
: outcome === "cancelled"
? (latestRun?.errorCode ?? "cancelled")
: outcome === "failed"
- ? (adapterResult.errorCode ?? "adapter_failed")
+ ? (adapterResult.errorCode ?? recordedResponsibleUserDenialCode ?? "adapter_failed")
: null;
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -11742,8 +12021,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
const workspaceValidationFailure = isWorkspaceValidationFailure(err) ? err : null;
const configurationIncompleteFailure = isConfigurationIncompleteFailure(err) ? err : null;
+ const recordedResponsibleUserDenialCode =
+ normalizeResponsibleUserDenialCode((await getRun(run.id).catch(() => null))?.errorCode);
const failureErrorCode =
- workspaceValidationFailure?.code ?? configurationIncompleteFailure?.code ?? "adapter_failed";
+ workspaceValidationFailure?.code
+ ?? configurationIncompleteFailure?.code
+ ?? recordedResponsibleUserDenialCode
+ ?? "adapter_failed";
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -11850,8 +12134,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
// recovery path routes it to a human owner instead of looping retries.
const workspaceValidationSetupFailure = isWorkspaceValidationFailure(outerErr) ? outerErr : null;
const configurationIncompleteSetupFailure = isConfigurationIncompleteFailure(outerErr) ? outerErr : null;
+ const recordedResponsibleUserDenialCode =
+ normalizeResponsibleUserDenialCode((await getRun(runId).catch(() => null))?.errorCode);
const setupFailureErrorCode =
- workspaceValidationSetupFailure?.code ?? configurationIncompleteSetupFailure?.code ?? "setup_failed";
+ workspaceValidationSetupFailure?.code ??
+ configurationIncompleteSetupFailure?.code ??
+ recordedResponsibleUserDenialCode ??
+ "setup_failed";
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", {
@@ -12310,6 +12599,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const promotedContinuationAttempt = readContinuationAttempt(
promotedContextSnapshot.livenessContinuationAttempt,
);
+ const promotedResponsibleUserId = await resolveResponsibleUserIdForRunSeed({
+ companyId: deferredAgent.companyId,
+ contextSnapshot: promotedContextSnapshot,
+ issueContext: issue,
+ routineEnvContext: await getRoutineEnvForExecutionIssue(deferredAgent.companyId, issue),
+ requestedByActorType: deferred.requestedByActorType as "user" | "agent" | "system" | null,
+ requestedByActorId: deferred.requestedByActorId,
+ source: promotedSource,
+ triggerDetail: promotedTriggerDetail,
+ existingRunResponsibleUserId: run.responsibleUserId,
+ });
+ if (!promotedResponsibleUserId) {
+ throw new HttpError(422, "Unable to resolve responsible user for promoted heartbeat run", {
+ code: "responsible_user_unresolved",
+ runId: run.id,
+ agentId: deferredAgent.id,
+ companyId: deferredAgent.companyId,
+ issueId: issue.id,
+ wakeReason: readNonEmptyString(promotedContextSnapshot.wakeReason),
+ });
+ }
const now = new Date();
const newRun = await tx
.insert(heartbeatRuns)
@@ -12321,6 +12631,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
status: "queued",
wakeupRequestId: deferred.id,
contextSnapshot: promotedContextSnapshot,
+ responsibleUserId: promotedResponsibleUserId,
sessionIdBefore: sessionBefore,
continuationAttempt: promotedContinuationAttempt,
})
@@ -12566,6 +12877,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const recoverySource =
issue.status === "todo" ? "issue.assignment_recovery" : "issue.continuation_recovery";
const now = new Date();
+ const recoveryContextSnapshot = withRecoveryModelProfileHint({
+ issueId: issue.id,
+ taskId: issue.id,
+ wakeReason: recoveryReason,
+ retryReason,
+ source: recoverySource,
+ retryOfRunId: run.id,
+ }, "normal_model");
+ const responsibleUserId = await resolveResponsibleUserIdForRunSeed({
+ companyId: issue.companyId,
+ contextSnapshot: recoveryContextSnapshot,
+ issueContext: issue,
+ routineEnvContext: await getRoutineEnvForExecutionIssue(issue.companyId, issue),
+ requestedByActorType: "system",
+ requestedByActorId: null,
+ source: "automation",
+ triggerDetail: "system",
+ existingRunResponsibleUserId: run.responsibleUserId,
+ });
+ if (!responsibleUserId) {
+ throw new HttpError(422, "Unable to resolve responsible user for recovery heartbeat run", {
+ code: "responsible_user_unresolved",
+ runId: run.id,
+ agentId: recoveryAgent.id,
+ companyId: issue.companyId,
+ issueId: issue.id,
+ wakeReason: recoveryReason,
+ });
+ }
const wakeupRequest = await tx
.insert(agentWakeupRequests)
.values({
@@ -12595,14 +12935,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
- contextSnapshot: withRecoveryModelProfileHint({
- issueId: issue.id,
- taskId: issue.id,
- wakeReason: recoveryReason,
- retryReason,
- source: recoverySource,
- retryOfRunId: run.id,
- }, "normal_model"),
+ contextSnapshot: recoveryContextSnapshot,
+ responsibleUserId,
sessionIdBefore: recoverySessionBefore,
retryOfRunId: run.id,
updatedAt: now,
@@ -12808,6 +13142,36 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
enrichedContextSnapshot.projectId = projectId;
}
+ let queuedResponsibleUserIdPromise: Promise | null = null;
+ const resolveQueuedResponsibleUserId = () => {
+ queuedResponsibleUserIdPromise ??= (async () => {
+ const queuedIssueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null;
+ const queuedRoutineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, queuedIssueContext);
+ const queuedResponsibleUserId = await resolveResponsibleUserIdForRunSeed({
+ companyId: agent.companyId,
+ contextSnapshot: enrichedContextSnapshot,
+ issueContext: queuedIssueContext,
+ routineEnvContext: queuedRoutineEnvContext,
+ requestedByActorType: opts.requestedByActorType ?? null,
+ requestedByActorId: opts.requestedByActorId ?? null,
+ source,
+ triggerDetail,
+ });
+ if (!queuedResponsibleUserId) {
+ throw new HttpError(422, "Unable to resolve responsible user for heartbeat run dispatch", {
+ code: "responsible_user_unresolved",
+ agentId,
+ companyId: agent.companyId,
+ issueId: issueId ?? null,
+ source,
+ triggerDetail,
+ wakeReason: readNonEmptyString(enrichedContextSnapshot.wakeReason),
+ });
+ }
+ return queuedResponsibleUserId;
+ })();
+ return queuedResponsibleUserIdPromise;
+ };
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {
issueId,
@@ -13394,6 +13758,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
invocationSource: source,
triggerDetail,
status: "queued",
+ responsibleUserId: await resolveQueuedResponsibleUserId(),
wakeupRequestId: wakeupRequest.id,
contextSnapshot: enrichedContextSnapshot,
sessionIdBefore: sessionBefore,
@@ -13459,7 +13824,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
Boolean(sameScopeRunningRun) &&
!sameScopeQueuedRun &&
shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId });
-
const rawCoalescedTarget =
sameScopeQueuedRun ??
sameScopeScheduledRetryRun ??
@@ -13568,6 +13932,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
invocationSource: source,
triggerDetail,
status: "queued",
+ responsibleUserId: await resolveQueuedResponsibleUserId(),
wakeupRequestId: wakeupRequest.id,
contextSnapshot: enrichedContextSnapshot,
sessionIdBefore: sessionBefore,
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index ca2f48959e..330f57117f 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -15,6 +15,7 @@ import {
documents,
goals,
heartbeatRuns,
+ routineRuns,
executionWorkspaces,
issueApprovals,
issueAttachments,
@@ -144,6 +145,60 @@ function readStringFromRecord(record: unknown, key: string) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
+async function resolveResponsibleUserIdForIssueCreate(
+ reader: DbReader,
+ companyId: string,
+ input: {
+ explicitResponsibleUserId?: string | null;
+ createdByUserId?: string | null;
+ parentId?: string | null;
+ originKind?: string | null;
+ originRunId?: string | null;
+ actorRunId?: string | null;
+ actorResponsibleUserId?: string | null;
+ trustExplicitResponsibleUserId?: boolean;
+ },
+) {
+ const explicitResponsibleUserId = readStringFromRecord(input, "explicitResponsibleUserId");
+ if (explicitResponsibleUserId && input.trustExplicitResponsibleUserId === true) return explicitResponsibleUserId;
+
+ if (input.originKind === "routine_execution" && input.originRunId) {
+ const routineRun = await reader
+ .select({ responsibleUserId: routineRuns.responsibleUserId })
+ .from(routineRuns)
+ .where(and(eq(routineRuns.companyId, companyId), eq(routineRuns.id, input.originRunId)))
+ .then((rows) => rows[0] ?? null);
+ if (routineRun?.responsibleUserId) return routineRun.responsibleUserId;
+ }
+
+ const actorResponsibleUserId = readStringFromRecord(input, "actorResponsibleUserId");
+ if (actorResponsibleUserId) return actorResponsibleUserId;
+
+ if (input.actorRunId) {
+ const actorRun = await reader
+ .select({ responsibleUserId: heartbeatRuns.responsibleUserId })
+ .from(heartbeatRuns)
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, input.actorRunId)))
+ .then((rows) => rows[0] ?? null);
+ if (actorRun?.responsibleUserId) return actorRun.responsibleUserId;
+ }
+
+ if (input.parentId) {
+ const parent = await reader
+ .select({
+ responsibleUserId: issues.responsibleUserId,
+ createdByUserId: issues.createdByUserId,
+ })
+ .from(issues)
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, input.parentId)))
+ .then((rows) => rows[0] ?? null);
+ if (parent?.responsibleUserId) return parent.responsibleUserId;
+ if (parent?.createdByUserId) return parent.createdByUserId;
+ }
+
+ return input.createdByUserId ?? null;
+}
+
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
settings: ReturnType,
) {
@@ -413,6 +468,9 @@ type IssueCreateInput = Omit & {
inheritExecutionWorkspaceFromIssueId?: string | null;
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
+ actorRunId?: string | null;
+ actorResponsibleUserId?: string | null;
+ trustExplicitResponsibleUserId?: boolean;
};
type IssueChildCreateInput = IssueCreateInput & {
acceptanceCriteria?: string[];
@@ -2217,6 +2275,7 @@ const issueListSelect = {
executionLockedAt: issues.executionLockedAt,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
+ responsibleUserId: issues.responsibleUserId,
issueNumber: issues.issueNumber,
identifier: issues.identifier,
originKind: issues.originKind,
@@ -4982,6 +5041,8 @@ export function issueService(db: Db) {
parentId: parent.id,
projectId: issueData.projectId ?? parent.projectId,
goalId: issueData.goalId ?? parent.goalId,
+ actorResponsibleUserId: issueData.actorResponsibleUserId ?? null,
+ trustExplicitResponsibleUserId: issueData.trustExplicitResponsibleUserId === true,
requestDepth: clampIssueRequestDepth(
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
),
@@ -5291,6 +5352,9 @@ export function issueService(db: Db) {
inheritExecutionWorkspaceFromIssueId,
watchdog,
watchdogActorRunId,
+ actorRunId,
+ actorResponsibleUserId,
+ trustExplicitResponsibleUserId,
...issueData
} = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
@@ -5436,9 +5500,20 @@ export function issueService(db: Db) {
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
+ const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(tx, companyId, {
+ explicitResponsibleUserId: issueData.responsibleUserId ?? null,
+ createdByUserId: issueData.createdByUserId ?? null,
+ parentId: issueData.parentId ?? null,
+ originKind: issueData.originKind ?? "manual",
+ originRunId: issueData.originRunId ?? null,
+ actorRunId: actorRunId ?? null,
+ actorResponsibleUserId: actorResponsibleUserId ?? null,
+ trustExplicitResponsibleUserId: trustExplicitResponsibleUserId === true,
+ });
const values = {
...issueData,
+ responsibleUserId,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
originKind: issueData.originKind ?? "manual",
goalId: resolveIssueGoalId({
diff --git a/server/src/services/pipelines.ts b/server/src/services/pipelines.ts
index 4a59fcfc86..558339fb52 100644
--- a/server/src/services/pipelines.ts
+++ b/server/src/services/pipelines.ts
@@ -1177,6 +1177,7 @@ function routineRevisionSnapshotRoutine(routine: typeof routines.$inferSelect):
originId: routine.originId,
variables: routine.variables ?? [],
env: routine.env ?? null,
+ responsibleUserId: routine.responsibleUserId ?? null,
};
}
diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts
index 5284af6ee6..0925820340 100644
--- a/server/src/services/plugin-host-services.ts
+++ b/server/src/services/plugin-host-services.ts
@@ -1557,6 +1557,8 @@ export function buildHostServices(
originRunId: params.originRunId ?? actorRunId ?? null,
createdByAgentId: actorAgentId ?? null,
createdByUserId: actorUserId ?? null,
+ actorResponsibleUserId: actorUserId ?? null,
+ trustExplicitResponsibleUserId: true,
})) as Issue;
await logPluginActivity({
companyId,
diff --git a/server/src/services/responsible-user-denial-run-outcomes.test.ts b/server/src/services/responsible-user-denial-run-outcomes.test.ts
new file mode 100644
index 0000000000..44d1afd29d
--- /dev/null
+++ b/server/src/services/responsible-user-denial-run-outcomes.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it, vi } from "vitest";
+import type { Db } from "@paperclipai/db";
+import {
+ normalizeResponsibleUserDenialCode,
+ recordResponsibleUserDenialOnActiveRun,
+} from "./responsible-user-denial-run-outcomes.js";
+
+const publishLiveEventMock = vi.hoisted(() => vi.fn());
+
+vi.mock("./live-events.js", () => ({
+ publishLiveEvent: publishLiveEventMock,
+}));
+
+function makeDbReturning(row: Record | null) {
+ const returning = vi.fn(() => Promise.resolve(row ? [row] : []));
+ const where = vi.fn(() => ({ returning }));
+ const set = vi.fn(() => ({ where }));
+ const update = vi.fn(() => ({ set }));
+ return {
+ db: { update } as unknown as Db,
+ update,
+ set,
+ where,
+ returning,
+ };
+}
+
+describe("responsible-user denial run outcomes", () => {
+ it("normalizes only responsible-user denial codes", () => {
+ expect(normalizeResponsibleUserDenialCode("RESPONSIBLE_USER_UNAUTHORIZED")).toBe(
+ "RESPONSIBLE_USER_UNAUTHORIZED",
+ );
+ expect(normalizeResponsibleUserDenialCode("RESPONSIBLE_USER_UNAVAILABLE")).toBe(
+ "RESPONSIBLE_USER_UNAVAILABLE",
+ );
+ expect(normalizeResponsibleUserDenialCode("access_denied")).toBeNull();
+ expect(normalizeResponsibleUserDenialCode(null)).toBeNull();
+ });
+
+ it("records the code on an active run and publishes the live status payload", async () => {
+ publishLiveEventMock.mockReset();
+ const startedAt = new Date("2026-07-02T10:00:00.000Z");
+ const row = {
+ id: "run-1",
+ companyId: "company-1",
+ agentId: "agent-1",
+ status: "running",
+ invocationSource: "on_demand",
+ triggerDetail: null,
+ error: null,
+ errorCode: "RESPONSIBLE_USER_UNAUTHORIZED",
+ startedAt,
+ finishedAt: null,
+ };
+ const { db, update, set } = makeDbReturning(row);
+
+ await recordResponsibleUserDenialOnActiveRun(db, {
+ runId: "run-1",
+ agentId: "agent-1",
+ companyId: "company-1",
+ code: "RESPONSIBLE_USER_UNAUTHORIZED",
+ });
+
+ expect(update).toHaveBeenCalledTimes(1);
+ expect(set).toHaveBeenCalledWith(expect.objectContaining({
+ errorCode: "RESPONSIBLE_USER_UNAUTHORIZED",
+ }));
+ expect(publishLiveEventMock).toHaveBeenCalledWith({
+ companyId: "company-1",
+ type: "heartbeat.run.status",
+ payload: expect.objectContaining({
+ runId: "run-1",
+ agentId: "agent-1",
+ status: "running",
+ errorCode: "RESPONSIBLE_USER_UNAUTHORIZED",
+ startedAt: startedAt.toISOString(),
+ finishedAt: null,
+ }),
+ });
+ });
+
+ it("ignores unrelated error codes before touching the database", async () => {
+ const { db, update } = makeDbReturning(null);
+
+ await recordResponsibleUserDenialOnActiveRun(db, {
+ runId: "run-1",
+ code: "access_denied",
+ });
+
+ expect(update).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/src/services/responsible-user-denial-run-outcomes.ts b/server/src/services/responsible-user-denial-run-outcomes.ts
new file mode 100644
index 0000000000..a2ef935713
--- /dev/null
+++ b/server/src/services/responsible-user-denial-run-outcomes.ts
@@ -0,0 +1,75 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { heartbeatRuns, type Db } from "@paperclipai/db";
+import {
+ isResponsibleUserDenialCode,
+ type ResponsibleUserDenialCode,
+} from "@paperclipai/shared";
+import { logger } from "../middleware/logger.js";
+import { publishLiveEvent } from "./live-events.js";
+
+export function normalizeResponsibleUserDenialCode(
+ code: unknown,
+): ResponsibleUserDenialCode | null {
+ return typeof code === "string" && isResponsibleUserDenialCode(code) ? code : null;
+}
+
+export async function recordResponsibleUserDenialOnActiveRun(
+ db: Db,
+ input: {
+ runId?: string | null;
+ agentId?: string | null;
+ companyId?: string | null;
+ code: unknown;
+ },
+) {
+ const runId = input.runId?.trim();
+ const code = normalizeResponsibleUserDenialCode(input.code);
+ if (!runId || !code) return null;
+
+ const conditions = [
+ eq(heartbeatRuns.id, runId),
+ inArray(heartbeatRuns.status, ["queued", "running"]),
+ ];
+ if (input.agentId) conditions.push(eq(heartbeatRuns.agentId, input.agentId));
+ if (input.companyId) conditions.push(eq(heartbeatRuns.companyId, input.companyId));
+
+ const updated = await db
+ .update(heartbeatRuns)
+ .set({
+ errorCode: code,
+ updatedAt: new Date(),
+ })
+ .where(and(...conditions))
+ .returning()
+ .then((rows) => rows[0] ?? null);
+
+ if (!updated) return null;
+
+ publishLiveEvent({
+ companyId: updated.companyId,
+ type: "heartbeat.run.status",
+ payload: {
+ runId: updated.id,
+ agentId: updated.agentId,
+ status: updated.status,
+ invocationSource: updated.invocationSource,
+ triggerDetail: updated.triggerDetail,
+ error: updated.error ?? null,
+ errorCode: updated.errorCode ?? null,
+ startedAt: updated.startedAt ? new Date(updated.startedAt).toISOString() : null,
+ finishedAt: updated.finishedAt ? new Date(updated.finishedAt).toISOString() : null,
+ },
+ });
+
+ logger.info(
+ {
+ runId: updated.id,
+ agentId: updated.agentId,
+ companyId: updated.companyId,
+ errorCode: code,
+ },
+ "recorded responsible-user denial code on active heartbeat run",
+ );
+
+ return updated;
+}
diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts
index 3fae80a27d..5fcd373dae 100644
--- a/server/src/services/routines.ts
+++ b/server/src/services/routines.ts
@@ -3,6 +3,8 @@ import { and, asc, desc, eq, inArray, isNotNull, isNull, lte, ne, not, or, sql }
import type { Db } from "@paperclipai/db";
import {
agents,
+ companies,
+ companyMemberships,
companySecretBindings,
companySecretVersions,
companySecrets,
@@ -81,6 +83,45 @@ const WEEKDAY_INDEX: Record = {
Sat: 6,
};
+async function resolveCompanyDefaultResponsibleUserId(db: Db, companyId: string) {
+ const company = await db
+ .select({ defaultResponsibleUserId: companies.defaultResponsibleUserId })
+ .from(companies)
+ .where(eq(companies.id, companyId))
+ .then((rows) => rows[0] ?? null);
+ if (company?.defaultResponsibleUserId) return company.defaultResponsibleUserId;
+
+ const owner = await db
+ .select({ userId: companyMemberships.principalId })
+ .from(companyMemberships)
+ .where(
+ and(
+ eq(companyMemberships.companyId, companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.status, "active"),
+ eq(companyMemberships.membershipRole, "owner"),
+ ),
+ )
+ .orderBy(asc(companyMemberships.createdAt), asc(companyMemberships.id))
+ .limit(1)
+ .then((rows) => rows[0] ?? null);
+ return owner?.userId ?? null;
+}
+
+async function resolveRoutineResponsibleUserId(db: Db, companyId: string, actorUserId: string | null | undefined, parentIssueId?: string | null) {
+ if (actorUserId) return actorUserId;
+ if (parentIssueId) {
+ const parent = await db
+ .select({ responsibleUserId: issues.responsibleUserId, createdByUserId: issues.createdByUserId })
+ .from(issues)
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, parentIssueId)))
+ .then((rows) => rows[0] ?? null);
+ if (parent?.responsibleUserId) return parent.responsibleUserId;
+ if (parent?.createdByUserId) return parent.createdByUserId;
+ }
+ return resolveCompanyDefaultResponsibleUserId(db, companyId);
+}
+
type Actor = { agentId?: string | null; userId?: string | null; runId?: string | null };
type RoutineRow = typeof routines.$inferSelect;
type RoutineTriggerRow = typeof routineTriggers.$inferSelect;
@@ -452,6 +493,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna
catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"],
variables: routine.variables ?? [],
env: routine.env ?? null,
+ responsibleUserId: routine.responsibleUserId ?? null,
};
}
@@ -819,6 +861,7 @@ export function routineService(
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
createdByRunId: actor.runId ?? null,
+ responsibleUserId: snapshot.routine.responsibleUserId ?? null,
createdAt: now,
})
.returning();
@@ -1136,6 +1179,7 @@ export function routineService(
completedAt: triggeredAt,
linkedIssueId: null,
routineRevisionId: input.routine.latestRevisionId,
+ responsibleUserId: input.routine.responsibleUserId ?? null,
})
.returning();
await updateRoutineTouchedState({
@@ -1485,6 +1529,26 @@ export function routineService(
const triggeredAt = new Date();
const manualRunnerUserId = input.source === "manual" ? input.actor?.userId ?? null : null;
+ const latestRevisionResponsibleUserId = input.routine.latestRevisionId
+ ? await txDb
+ .select({
+ responsibleUserId: routineRevisions.responsibleUserId,
+ snapshot: routineRevisions.snapshot,
+ })
+ .from(routineRevisions)
+ .where(and(
+ eq(routineRevisions.companyId, input.routine.companyId),
+ eq(routineRevisions.routineId, input.routine.id),
+ eq(routineRevisions.id, input.routine.latestRevisionId),
+ ))
+ .then((rows) => {
+ const row = rows[0] ?? null;
+ const snapshot = row?.snapshot as RoutineRevisionSnapshotV1 | undefined;
+ return row?.responsibleUserId ?? snapshot?.routine.responsibleUserId ?? null;
+ })
+ : null;
+ const responsibleUserId =
+ manualRunnerUserId ?? latestRevisionResponsibleUserId ?? input.routine.responsibleUserId ?? null;
const [createdRun] = await txDb
.insert(routineRuns)
.values({
@@ -1498,6 +1562,7 @@ export function routineService(
triggerPayload,
dispatchFingerprint,
routineRevisionId: input.routine.latestRevisionId,
+ responsibleUserId,
})
.returning();
@@ -1551,6 +1616,8 @@ export function routineService(
assigneeAgentId,
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
createdByUserId: manualRunnerUserId,
+ responsibleUserId,
+ trustExplicitResponsibleUserId: true,
originKind: issueOriginKind,
originId: issueOriginId,
originRunId: createdRun.id,
@@ -1842,6 +1909,10 @@ export function routineService(
);
assertRoutineVariableDefinitions(variables);
const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId);
+ const responsibleUserId = await resolveRoutineResponsibleUserId(db, companyId, actor.userId, input.parentIssueId ?? null);
+ if (!responsibleUserId) {
+ throw unprocessable("Routine requires a responsible user");
+ }
const createdRoutine = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const [created] = await txDb
@@ -1860,6 +1931,7 @@ export function routineService(
catchUpPolicy: input.catchUpPolicy,
variables,
env,
+ responsibleUserId,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
updatedByAgentId: actor.agentId ?? null,
@@ -1930,6 +2002,15 @@ export function routineService(
if (enabledScheduleTriggers) {
assertScheduleCompatibleVariables(nextVariables);
}
+ const responsibleUserId = await resolveRoutineResponsibleUserId(
+ db,
+ existing.companyId,
+ actor.userId,
+ patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
+ );
+ if (!responsibleUserId) {
+ throw unprocessable("Routine requires a responsible user");
+ }
const updatedRoutine = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`);
@@ -1960,6 +2041,7 @@ export function routineService(
catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy,
variables: nextVariables,
env: nextEnv,
+ responsibleUserId: locked.responsibleUserId ?? responsibleUserId,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
};
@@ -2009,6 +2091,7 @@ export function routineService(
catchUpPolicy: candidate.catchUpPolicy,
variables: candidate.variables,
env: candidate.env,
+ responsibleUserId: candidate.responsibleUserId,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: new Date(),
diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts
index 860c5a7ff6..65cdbed8db 100644
--- a/server/src/services/secrets.ts
+++ b/server/src/services/secrets.ts
@@ -7,12 +7,15 @@ import {
companySecretProviderConfigs,
companySecrets,
companySecretVersions,
+ companyMemberships,
environments,
heartbeatRuns,
issues,
projects,
routines,
secretAccessEvents,
+ userSecretDeclarations,
+ userSecretDefinitions,
} from "@paperclipai/db";
import type {
AgentEnvConfig,
@@ -55,7 +58,7 @@ import type {
SecretProviderWriteContext,
} from "../secrets/types.js";
import { isSecretProviderClientError } from "../secrets/types.js";
-import { authorizationService } from "./authorization.js";
+import { authorizationDeniedDetails, authorizationService } from "./authorization.js";
import { findActiveServerAdapter } from "../adapters/index.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -69,9 +72,29 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([
const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly> = {
hermes_gateway: ["apiKey"],
};
+const USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT = "user_secret_definitions_company_key_uq";
+const USER_SECRET_VALUE_UNIQUE_CONSTRAINT = "company_secrets_user_definition_owner_uq";
type DbTransaction = Parameters[0]>[0];
type SecretBindingDb = Pick;
+function isUniqueConstraintViolation(error: unknown, constraintName: string) {
+ const seen = new Set();
+ let current = error;
+ while (typeof current === "object" && current !== null && !seen.has(current)) {
+ seen.add(current);
+ const maybe = current as {
+ code?: string;
+ constraint?: string;
+ constraint_name?: string;
+ cause?: unknown;
+ };
+ const constraint = maybe.constraint ?? maybe.constraint_name;
+ if (maybe.code === "23505" && constraint === constraintName) return true;
+ current = maybe.cause;
+ }
+ return false;
+}
+
function remoteProviderHttpError(error: unknown, context: {
companyId: string;
provider: SecretProvider;
@@ -216,12 +239,20 @@ async function cleanupPreparedProviderWrite(input: {
type CanonicalEnvBinding =
| { type: "plain"; value: string }
- | { type: "secret_ref"; secretId: string; version: number | "latest" };
+ | { type: "secret_ref"; secretId: string; version: number | "latest" }
+ | {
+ type: "user_secret_ref";
+ key: string;
+ version: number | "latest";
+ required: boolean;
+ allowMissingOverride: boolean;
+ };
type SecretConsumerContext = {
consumerType: SecretBindingTargetType;
consumerId: string;
configPath?: string | null;
+ responsibleUserId?: string | null;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant";
@@ -234,6 +265,7 @@ type SecretConsumerContext = {
type SecretResolutionOptions = {
bindingContext?: SecretConsumerContext;
accessContext?: SecretConsumerContext;
+ allowUserSecretScope?: boolean;
};
export type RuntimeSecretManifestEntry = {
@@ -254,8 +286,14 @@ export type MissingRuntimeBinding = {
consumerId: string;
configPath: string;
envKey: string;
- secretId: string;
+ bindingType?: "secret_ref" | "user_secret_ref";
+ secretId: string | null;
secretName: string | null;
+ userSecretDefinitionId?: string | null;
+ userSecretDefinitionKey?: string | null;
+ userSecretDefinitionName?: string | null;
+ responsibleUserId?: string | null;
+ errorCode?: SecretResolutionErrorCode;
};
type RuntimeSecretResolution = {
@@ -267,6 +305,11 @@ type SecretResolutionErrorCode =
| "binding_missing"
| "secret_deleted"
| "secret_inactive"
+ | "secret_scope_invalid"
+ | "responsible_user_missing"
+ | "user_secret_definition_missing"
+ | "user_secret_definition_inactive"
+ | "user_secret_missing"
| "version_missing"
| "version_inactive"
| "provider_error";
@@ -303,6 +346,15 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
if (binding.type === "plain") {
return { type: "plain", value: String(binding.value) };
}
+ if (binding.type === "user_secret_ref") {
+ return {
+ type: "user_secret_ref",
+ key: binding.key,
+ version: binding.version ?? "latest",
+ required: binding.required ?? true,
+ allowMissingOverride: binding.allowMissingOverride ?? false,
+ };
+ }
return {
type: "secret_ref",
secretId: binding.secretId,
@@ -328,6 +380,15 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
return details.code;
}
if (error.message === "Secret is not active") return "secret_inactive";
+ if (error.message === "User secret value is not configured") return "user_secret_missing";
+ if (error.message === "Responsible user is required for user secret resolution") {
+ return "responsible_user_missing";
+ }
+ if (error.message === "User secret definition not found") return "user_secret_definition_missing";
+ if (error.message === "User secret definition is not active") return "user_secret_definition_inactive";
+ if (error.message === "User-scoped secrets must be resolved through user secret declarations") {
+ return "secret_scope_invalid";
+ }
if (error.message === "Secret version not found") return "version_missing";
if (error.message === "Secret version is not active") return "version_inactive";
if (
@@ -341,6 +402,32 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
return "provider_error";
}
+function missingUserSecretDefinitionRuntimeBinding(
+ entry: {
+ key: string;
+ configPath: string;
+ binding: Extract;
+ },
+ context: Omit,
+ definition: typeof userSecretDefinitions.$inferSelect | null,
+ errorCode: "user_secret_definition_missing" | "user_secret_definition_inactive",
+): MissingRuntimeBinding {
+ return {
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: null,
+ secretName: null,
+ userSecretDefinitionId: definition?.id ?? null,
+ userSecretDefinitionKey: definition?.key ?? entry.binding.key,
+ userSecretDefinitionName: definition?.name ?? null,
+ responsibleUserId: context.responsibleUserId ?? null,
+ errorCode,
+ };
+}
+
function assertSelectableProviderConfig(config: {
provider: string;
status: string;
@@ -383,12 +470,92 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "company"),
eq(companySecrets.name, name),
ne(companySecrets.status, "deleted"),
))
.then((rows) => rows[0] ?? null);
}
+ async function getUserSecretDefinitionById(
+ companyId: string,
+ definitionId: string,
+ source: Pick = db,
+ ) {
+ return source
+ .select()
+ .from(userSecretDefinitions)
+ .where(and(
+ eq(userSecretDefinitions.companyId, companyId),
+ eq(userSecretDefinitions.id, definitionId),
+ ))
+ .then((rows) => rows[0] ?? null);
+ }
+
+ async function getUserSecretDefinitionByKey(
+ companyId: string,
+ key: string,
+ source: Pick = db,
+ ) {
+ return source
+ .select()
+ .from(userSecretDefinitions)
+ .where(and(
+ eq(userSecretDefinitions.companyId, companyId),
+ eq(userSecretDefinitions.key, key),
+ ne(userSecretDefinitions.status, "deleted"),
+ ))
+ .then((rows) => rows[0] ?? null);
+ }
+
+ async function resolveUserSecretDefinition(
+ companyId: string,
+ input: { definitionId?: string | null; definitionKey?: string | null },
+ source: Pick = db,
+ ) {
+ const definition = input.definitionId
+ ? await getUserSecretDefinitionById(companyId, input.definitionId, source)
+ : input.definitionKey
+ ? await getUserSecretDefinitionByKey(companyId, input.definitionKey, source)
+ : null;
+ if (!definition || definition.deletedAt || definition.status === "deleted") {
+ throw notFound("User secret definition not found");
+ }
+ if (definition.companyId !== companyId) {
+ throw unprocessable("User secret definition must belong to same company");
+ }
+ return definition;
+ }
+
+ async function getUserSecretValue(input: {
+ companyId: string;
+ ownerUserId: string;
+ definitionId: string;
+ }) {
+ return db
+ .select()
+ .from(companySecrets)
+ .where(and(
+ eq(companySecrets.companyId, input.companyId),
+ eq(companySecrets.scope, "user"),
+ eq(companySecrets.ownerUserId, input.ownerUserId),
+ eq(companySecrets.userSecretDefinitionId, input.definitionId),
+ ne(companySecrets.status, "deleted"),
+ ))
+ .then((rows) => rows[0] ?? null);
+ }
+
+ async function getUserSecretValueById(companyId: string, ownerUserId: string, secretId: string) {
+ const secret = await getById(secretId);
+ if (!secret || secret.status === "deleted" || secret.scope !== "user") {
+ throw notFound("User secret value not found");
+ }
+ if (secret.companyId !== companyId || secret.ownerUserId !== ownerUserId) {
+ throw notFound("User secret value not found");
+ }
+ return secret;
+ }
+
async function getSecretVersion(secretId: string, version: number) {
return db
.select()
@@ -461,9 +628,14 @@ export function secretService(db: Db) {
async function recordAccessEvent(input: {
companyId: string;
secretId: string;
+ userSecretDefinitionId?: string | null;
+ secretScope?: string | null;
version: number | null;
provider: SecretProvider;
context: SecretConsumerContext | undefined;
+ credentialOwnerUserId?: string | null;
+ credentialSubjectType?: string | null;
+ credentialSubjectId?: string | null;
outcome: "success" | "failure";
errorCode?: string | null;
}) {
@@ -471,8 +643,14 @@ export function secretService(db: Db) {
await db.insert(secretAccessEvents).values({
companyId: input.companyId,
secretId: input.secretId,
+ userSecretDefinitionId: input.userSecretDefinitionId ?? null,
+ secretScope: input.secretScope ?? "company",
version: input.version,
provider: input.provider,
+ responsibleUserId: input.context.responsibleUserId ?? null,
+ credentialOwnerUserId: input.credentialOwnerUserId ?? null,
+ credentialSubjectType: input.credentialSubjectType ?? null,
+ credentialSubjectId: input.credentialSubjectId ?? null,
actorType: input.context.actorType ?? "system",
actorId: input.context.actorId ?? null,
consumerType: input.context.consumerType,
@@ -495,6 +673,7 @@ export function secretService(db: Db) {
if (!secret) throw notFound("Secret not found");
if (secret.status === "deleted") throw notFound("Secret not found");
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
+ if (secret.scope !== "company") throw unprocessable("Secret references require company-scoped secrets");
return secret;
}
@@ -638,6 +817,11 @@ export function secretService(db: Db) {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
+ if (secret.scope !== "company" && !options?.allowUserSecretScope) {
+ throw unprocessable("User-scoped secrets must be resolved through user secret declarations", {
+ code: "secret_scope_invalid",
+ });
+ }
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
const providerId = secret.provider as SecretProvider;
const configPath = accessContext?.configPath ?? null;
@@ -681,9 +865,14 @@ export function secretService(db: Db) {
recordAccessEvent({
companyId,
secretId: secret.id,
+ userSecretDefinitionId: secret.userSecretDefinitionId ?? null,
+ secretScope: secret.scope,
version: resolvedVersion,
provider: providerId,
context: accessContext,
+ credentialOwnerUserId: secret.ownerUserId ?? null,
+ credentialSubjectType: secret.scope === "user" ? "user" : null,
+ credentialSubjectId: secret.ownerUserId ?? null,
outcome: "success",
}).catch(() => undefined),
]);
@@ -706,9 +895,14 @@ export function secretService(db: Db) {
await recordAccessEvent({
companyId,
secretId: secret.id,
+ userSecretDefinitionId: secret.userSecretDefinitionId ?? null,
+ secretScope: secret.scope,
version: resolvedVersion,
provider: providerId,
context: accessContext,
+ credentialOwnerUserId: secret.ownerUserId ?? null,
+ credentialSubjectType: secret.scope === "user" ? "user" : null,
+ credentialSubjectId: secret.ownerUserId ?? null,
outcome: "failure",
errorCode,
}).catch(() => undefined);
@@ -768,7 +962,7 @@ export function secretService(db: Db) {
resource: { type: "company", companyId },
});
if (!decision.allowed) {
- throw forbidden(decision.explanation);
+ throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
return (await resolveSecretValueInternal(companyId, secretId, version, {
accessContext: context,
@@ -807,6 +1001,10 @@ export function secretService(db: Db) {
normalized[key] = binding;
continue;
}
+ if (binding.type === "user_secret_ref") {
+ normalized[key] = binding;
+ continue;
+ }
await assertSecretInCompany(companyId, binding.secretId);
normalized[key] = {
@@ -887,6 +1085,9 @@ export function secretService(db: Db) {
version: binding.version,
};
}
+ if (binding.type === "user_secret_ref") {
+ throw unprocessable(`${input.key} must be a string, plain binding, or company secret reference`);
+ }
const value = binding.value.trim();
if (!value) return undefined;
if (value === REDACTED_SENTINEL) {
@@ -927,6 +1128,7 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "company"),
eq(companySecrets.key, key),
ne(companySecrets.status, "deleted"),
))
@@ -1288,6 +1490,240 @@ export function secretService(db: Db) {
return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) };
}
+ async function createUserSecretValueInternal(
+ companyId: string,
+ ownerUserId: string,
+ input: {
+ definitionId?: string | null;
+ definitionKey?: string | null;
+ value?: string | null;
+ externalRef?: string | null;
+ providerVersionRef?: string | null;
+ providerConfigId?: string | null;
+ },
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) {
+ const definition = await resolveUserSecretDefinition(companyId, input);
+ if (definition.status !== "active") {
+ throw unprocessable("User secret definition is not active");
+ }
+ const existing = await getUserSecretValue({
+ companyId,
+ ownerUserId,
+ definitionId: definition.id,
+ });
+ if (existing) throw conflict("User secret value already exists");
+
+ const providerId = definition.provider as SecretProvider;
+ const managedMode = definition.managedMode as "paperclip_managed" | "external_reference";
+ if (managedMode === "external_reference" && !input.externalRef?.trim()) {
+ throw unprocessable("External reference user secrets require externalRef");
+ }
+ if (managedMode === "paperclip_managed" && input.externalRef?.trim()) {
+ throw unprocessable("Managed user secrets cannot override externalRef");
+ }
+ if (managedMode === "paperclip_managed" && !input.value?.trim()) {
+ throw unprocessable("Managed user secrets require value");
+ }
+
+ const providerConfigId =
+ input.providerConfigId === undefined ? definition.providerConfigId : input.providerConfigId;
+ const provider = getSecretProvider(providerId);
+ const providerConfig = await getSelectableRuntimeProviderConfig({
+ companyId,
+ provider: providerId,
+ providerConfigId,
+ });
+ const idSuffix = randomUUID();
+ const key = normalizeSecretKey(`user.${definition.key}.${idSuffix}`);
+ const name = `${definition.name} (${ownerUserId})`;
+ const providerWriteContext = {
+ companyId,
+ secretKey: key,
+ secretName: definition.name,
+ version: 1,
+ };
+ let reservedSecret: typeof companySecrets.$inferSelect;
+ try {
+ reservedSecret = await db
+ .insert(companySecrets)
+ .values({
+ companyId,
+ scope: "user",
+ ownerUserId,
+ userSecretDefinitionId: definition.id,
+ key,
+ name,
+ provider: providerId,
+ providerConfigId: providerConfigId ?? null,
+ status: "archived",
+ managedMode,
+ externalRef: null,
+ providerMetadata: definition.providerMetadata ?? null,
+ latestVersion: 0,
+ description: definition.description ?? null,
+ createdByAgentId: actor?.agentId ?? null,
+ createdByUserId: actor?.userId ?? null,
+ })
+ .returning()
+ .then((rows) => rows[0]);
+ } catch (error) {
+ if (isUniqueConstraintViolation(error, USER_SECRET_VALUE_UNIQUE_CONSTRAINT)) {
+ throw conflict("User secret value already exists");
+ }
+ throw error;
+ }
+
+ let prepared: PreparedSecretVersion;
+ try {
+ prepared =
+ managedMode === "external_reference"
+ ? await provider.linkExternalSecret({
+ externalRef: input.externalRef ?? "",
+ providerVersionRef: input.providerVersionRef ?? null,
+ providerConfig,
+ context: providerWriteContext,
+ })
+ : await provider.createSecret({
+ value: input.value ?? "",
+ externalRef: null,
+ providerConfig,
+ context: providerWriteContext,
+ });
+ } catch (error) {
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
+ throw error;
+ }
+
+ try {
+ return await db.transaction(async (tx) => {
+ await tx.insert(companySecretVersions).values({
+ secretId: reservedSecret.id,
+ version: 1,
+ material: prepared.material,
+ valueSha256: prepared.valueSha256,
+ fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
+ providerVersionRef: prepared.providerVersionRef ?? null,
+ status: "current",
+ createdByAgentId: actor?.agentId ?? null,
+ createdByUserId: actor?.userId ?? null,
+ });
+ const secret = await tx
+ .update(companySecrets)
+ .set({
+ status: "active",
+ externalRef: prepared.externalRef,
+ latestVersion: 1,
+ lastRotatedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(companySecrets.id, reservedSecret.id))
+ .returning()
+ .then((rows) => rows[0]);
+ if (!secret) throw notFound("User secret value not found");
+ return secret;
+ });
+ } catch (error) {
+ if (managedMode === "paperclip_managed") {
+ await cleanupPreparedProviderWrite({
+ provider,
+ prepared,
+ providerConfig,
+ context: providerWriteContext,
+ mode: "delete",
+ operation: "user_secret_value.create_rollback",
+ }).catch(() => false);
+ }
+ await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined);
+ await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined);
+ throw error;
+ }
+ }
+
+ async function removeSecretInternal(secretId: string) {
+ const secret = await getById(secretId);
+ if (!secret) return null;
+ const versionRow = await getSecretVersion(secret.id, secret.latestVersion);
+ const providerId = secret.provider as SecretProvider;
+ const provider = getSecretProvider(providerId);
+ if (secret.status !== "deleted") {
+ await db
+ .update(companySecrets)
+ .set({
+ key: `${secret.key}__deleted__${secret.id}`,
+ name: `${secret.name}__deleted__${secret.id}`,
+ status: "deleted",
+ deletedAt: secret.deletedAt ?? new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(companySecrets.id, secretId));
+ }
+ const providerConfig = secret.providerConfigId
+ ? await getProviderConfigById(secret.providerConfigId)
+ : null;
+ const providerRuntimeConfig =
+ providerConfig && providerConfig.status !== "disabled" && providerConfig.status !== "coming_soon"
+ ? toProviderVaultRuntimeConfig(providerConfig)
+ : null;
+ if (!secret.providerConfigId || providerRuntimeConfig) {
+ try {
+ await provider.deleteOrArchive({
+ material: versionRow?.material as Record | undefined,
+ externalRef: secret.externalRef,
+ providerConfig: providerRuntimeConfig,
+ context: {
+ companyId: secret.companyId,
+ secretKey: secret.key,
+ secretName: secret.name,
+ version: secret.latestVersion,
+ },
+ mode: "delete",
+ });
+ } catch (error) {
+ if (!isSecretProviderClientError(error) || error.code !== "not_found") {
+ throw error;
+ }
+ }
+ }
+ await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
+ return secret;
+ }
+
+ async function removeUserSecretDefinitionInternal(
+ companyId: string,
+ definitionId: string,
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) {
+ const existing = await resolveUserSecretDefinition(companyId, { definitionId });
+ const values = await db
+ .select({ id: companySecrets.id })
+ .from(companySecrets)
+ .where(and(
+ eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "user"),
+ eq(companySecrets.userSecretDefinitionId, definitionId),
+ ));
+ for (const value of values) {
+ await removeSecretInternal(value.id);
+ }
+ return db
+ .update(userSecretDefinitions)
+ .set({
+ key: `${existing.key}__deleted__${existing.id}`,
+ status: "deleted",
+ deletedAt: existing.deletedAt ?? new Date(),
+ updatedByAgentId: actor?.agentId ?? null,
+ updatedByUserId: actor?.userId ?? null,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(userSecretDefinitions.companyId, companyId),
+ eq(userSecretDefinitions.id, definitionId),
+ ))
+ .returning()
+ .then((rows) => rows[0] ?? null);
+ }
+
return {
listProviders: () => listSecretProviders(),
@@ -1545,7 +1981,11 @@ export function secretService(db: Db) {
db
.select()
.from(companySecrets)
- .where(and(eq(companySecrets.companyId, companyId), ne(companySecrets.status, "deleted")))
+ .where(and(
+ eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "company"),
+ ne(companySecrets.status, "deleted"),
+ ))
.orderBy(desc(companySecrets.createdAt)),
db
.select({
@@ -1596,6 +2036,432 @@ export function secretService(db: Db) {
.where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId)))
.orderBy(desc(secretAccessEvents.createdAt)),
+ listUserSecretDefinitions: (companyId: string) =>
+ db
+ .select()
+ .from(userSecretDefinitions)
+ .where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted")))
+ .orderBy(desc(userSecretDefinitions.createdAt)),
+
+ getUserSecretDefinitionById: (companyId: string, definitionId: string) =>
+ getUserSecretDefinitionById(companyId, definitionId),
+
+ createUserSecretDefinition: async (
+ companyId: string,
+ input: {
+ key: string;
+ name: string;
+ description?: string | null;
+ status?: string;
+ provider: SecretProvider;
+ providerConfigId?: string | null;
+ managedMode?: "paperclip_managed" | "external_reference";
+ providerMetadata?: Record | null;
+ usageGuidance?: string | null;
+ },
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) => {
+ const key = input.key.trim();
+ const duplicate = await getUserSecretDefinitionByKey(companyId, key);
+ if (duplicate) throw conflict(`User secret definition already exists: ${key}`);
+ await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId);
+ try {
+ return await db
+ .insert(userSecretDefinitions)
+ .values({
+ companyId,
+ key,
+ name: input.name.trim(),
+ description: input.description ?? null,
+ status: input.status ?? "active",
+ provider: input.provider,
+ providerConfigId: input.providerConfigId ?? null,
+ managedMode: input.managedMode ?? "paperclip_managed",
+ providerMetadata: input.providerMetadata ?? null,
+ usageGuidance: input.usageGuidance ?? null,
+ createdByAgentId: actor?.agentId ?? null,
+ createdByUserId: actor?.userId ?? null,
+ updatedByAgentId: actor?.agentId ?? null,
+ updatedByUserId: actor?.userId ?? null,
+ })
+ .returning()
+ .then((rows) => rows[0]);
+ } catch (error) {
+ if (isUniqueConstraintViolation(error, USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT)) {
+ throw conflict(`User secret definition already exists: ${key}`);
+ }
+ throw error;
+ }
+ },
+
+ updateUserSecretDefinition: async (
+ companyId: string,
+ definitionId: string,
+ patch: {
+ key?: string;
+ name?: string;
+ description?: string | null;
+ status?: string;
+ providerConfigId?: string | null;
+ providerMetadata?: Record | null;
+ usageGuidance?: string | null;
+ },
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) => {
+ const existing = await resolveUserSecretDefinition(companyId, { definitionId });
+ if (patch.status === "deleted") {
+ return removeUserSecretDefinitionInternal(companyId, existing.id, actor);
+ }
+ const nextKey = patch.key?.trim() ?? existing.key;
+ if (nextKey !== existing.key) {
+ const duplicate = await getUserSecretDefinitionByKey(companyId, nextKey);
+ if (duplicate && duplicate.id !== existing.id) {
+ throw conflict(`User secret definition already exists: ${nextKey}`);
+ }
+ }
+ if (patch.providerConfigId !== undefined) {
+ await assertProviderConfigForSecret(
+ companyId,
+ existing.provider as SecretProvider,
+ patch.providerConfigId,
+ );
+ }
+ return db
+ .update(userSecretDefinitions)
+ .set({
+ key: nextKey,
+ name: patch.name?.trim() ?? existing.name,
+ description: patch.description === undefined ? existing.description : patch.description,
+ status: patch.status ?? existing.status,
+ providerConfigId:
+ patch.providerConfigId === undefined ? existing.providerConfigId : patch.providerConfigId,
+ providerMetadata:
+ patch.providerMetadata === undefined ? existing.providerMetadata : patch.providerMetadata,
+ usageGuidance:
+ patch.usageGuidance === undefined ? existing.usageGuidance : patch.usageGuidance,
+ updatedByAgentId: actor?.agentId ?? null,
+ updatedByUserId: actor?.userId ?? null,
+ deletedAt: existing.deletedAt,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(userSecretDefinitions.companyId, companyId),
+ eq(userSecretDefinitions.id, definitionId),
+ ))
+ .returning()
+ .then((rows) => rows[0] ?? null);
+ },
+
+ removeUserSecretDefinition: async (
+ companyId: string,
+ definitionId: string,
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) => removeUserSecretDefinitionInternal(companyId, definitionId, actor),
+
+ getUserSecretDefinitionCoverage: async (companyId: string, definitionId: string) => {
+ await resolveUserSecretDefinition(companyId, { definitionId });
+ const [members, values] = await Promise.all([
+ db
+ .select({ principalId: companyMemberships.principalId })
+ .from(companyMemberships)
+ .where(and(
+ eq(companyMemberships.companyId, companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.status, "active"),
+ )),
+ db
+ .select({ status: companySecrets.status, ownerUserId: companySecrets.ownerUserId })
+ .from(companySecrets)
+ .where(and(
+ eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "user"),
+ eq(companySecrets.userSecretDefinitionId, definitionId),
+ ne(companySecrets.status, "deleted"),
+ )),
+ ]);
+ const memberIds = new Set(members.map((member) => member.principalId));
+ const configuredCount = values.filter((value) =>
+ value.status === "active" && value.ownerUserId && memberIds.has(value.ownerUserId)
+ ).length;
+ const inactiveCount = values.filter((value) =>
+ value.status !== "active" && value.ownerUserId && memberIds.has(value.ownerUserId)
+ ).length;
+ return {
+ definitionId,
+ configuredCount,
+ inactiveCount,
+ missingCount: Math.max(0, memberIds.size - configuredCount - inactiveCount),
+ };
+ },
+
+ listCurrentUserSecretValues: async (companyId: string, ownerUserId: string) => {
+ const definitions = await db
+ .select()
+ .from(userSecretDefinitions)
+ .where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted")))
+ .orderBy(desc(userSecretDefinitions.createdAt));
+ const values = await db
+ .select()
+ .from(companySecrets)
+ .where(and(
+ eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "user"),
+ eq(companySecrets.ownerUserId, ownerUserId),
+ ne(companySecrets.status, "deleted"),
+ ));
+ const valuesByDefinitionId = new Map(values.map((value) => [value.userSecretDefinitionId, value]));
+ return definitions.map((definition) => ({
+ definition,
+ secret: valuesByDefinitionId.get(definition.id) ?? null,
+ }));
+ },
+
+ createCurrentUserSecretValue: createUserSecretValueInternal,
+
+ rotateCurrentUserSecretValue: async (
+ companyId: string,
+ ownerUserId: string,
+ secretId: string,
+ input: {
+ value?: string | null;
+ externalRef?: string | null;
+ providerVersionRef?: string | null;
+ providerConfigId?: string | null;
+ },
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) => {
+ const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
+ return await (async () => {
+ await resolveUserSecretDefinition(companyId, { definitionId: secret.userSecretDefinitionId });
+ return (await secretService(db).rotate(secret.id, input, actor));
+ })();
+ },
+
+ updateCurrentUserSecretValue: async (
+ companyId: string,
+ ownerUserId: string,
+ secretId: string,
+ patch: {
+ status?: "active" | "disabled" | "archived" | "deleted";
+ value?: string | null;
+ externalRef?: string | null;
+ providerVersionRef?: string | null;
+ providerConfigId?: string | null;
+ },
+ actor?: { userId?: string | null; agentId?: string | null },
+ ) => {
+ const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
+ if (
+ patch.value != null ||
+ patch.externalRef != null ||
+ patch.providerVersionRef != null ||
+ patch.providerConfigId != null
+ ) {
+ return await secretService(db).rotateCurrentUserSecretValue(
+ companyId,
+ ownerUserId,
+ secret.id,
+ patch,
+ actor,
+ );
+ }
+ if (patch.status === "deleted") {
+ return await secretService(db).removeCurrentUserSecretValue(companyId, ownerUserId, secret.id);
+ }
+ return db
+ .update(companySecrets)
+ .set({
+ status: patch.status ?? secret.status,
+ updatedAt: new Date(),
+ })
+ .where(eq(companySecrets.id, secret.id))
+ .returning()
+ .then((rows) => rows[0] ?? null);
+ },
+
+ removeCurrentUserSecretValue: async (companyId: string, ownerUserId: string, secretId: string) => {
+ const secret = await getUserSecretValueById(companyId, ownerUserId, secretId);
+ return await secretService(db).remove(secret.id);
+ },
+
+ syncUserSecretDeclarationsForTarget: async (
+ companyId: string,
+ target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string },
+ refs: Array<{
+ definitionKey: string;
+ configPath: string;
+ envKey: string;
+ versionSelector?: SecretVersionSelector;
+ required?: boolean;
+ allowMissingOverride?: boolean;
+ label?: string | null;
+ }>,
+ options?: { db?: SecretBindingDb; replaceAll?: boolean },
+ ) => {
+ const targetDb = options?.db ?? db;
+ const normalizedRefs: Array<{
+ definitionId: string;
+ configPath: string;
+ envKey: string;
+ versionSelector: SecretVersionSelector;
+ required: boolean;
+ allowMissingOverride: boolean;
+ label: string | null;
+ }> = [];
+ for (const ref of refs) {
+ const definition = await resolveUserSecretDefinition(
+ companyId,
+ { definitionKey: ref.definitionKey },
+ targetDb,
+ );
+ normalizedRefs.push({
+ definitionId: definition.id,
+ configPath: ref.configPath,
+ envKey: ref.envKey,
+ versionSelector: ref.versionSelector ?? "latest",
+ required: ref.required ?? true,
+ allowMissingOverride: ref.allowMissingOverride ?? false,
+ label: ref.label ?? null,
+ });
+ }
+
+ const pathPrefix = target.pathPrefix ?? "env";
+ const writeDeclarations = async (executor: SecretBindingDb) => {
+ if (options?.replaceAll) {
+ await executor
+ .delete(userSecretDeclarations)
+ .where(and(
+ eq(userSecretDeclarations.companyId, companyId),
+ eq(userSecretDeclarations.targetType, target.targetType),
+ eq(userSecretDeclarations.targetId, target.targetId),
+ ));
+ } else {
+ await executor
+ .delete(userSecretDeclarations)
+ .where(and(
+ eq(userSecretDeclarations.companyId, companyId),
+ eq(userSecretDeclarations.targetType, target.targetType),
+ eq(userSecretDeclarations.targetId, target.targetId),
+ like(userSecretDeclarations.configPath, `${pathPrefix}.%`),
+ ));
+ }
+ if (normalizedRefs.length === 0) return;
+ await executor.insert(userSecretDeclarations).values(
+ normalizedRefs.map((ref) => ({
+ companyId,
+ userSecretDefinitionId: ref.definitionId,
+ targetType: target.targetType,
+ targetId: target.targetId,
+ configPath: ref.configPath,
+ envKey: ref.envKey,
+ versionSelector: String(ref.versionSelector),
+ required: ref.required,
+ allowMissingOverride: ref.allowMissingOverride,
+ label: ref.label,
+ })),
+ );
+ };
+
+ if (options?.db) {
+ await writeDeclarations(targetDb);
+ } else {
+ await db.transaction(async (tx) => writeDeclarations(tx));
+ }
+ return normalizedRefs;
+ },
+
+ resolveUserSecretValue: async (
+ companyId: string,
+ input: {
+ definitionKey?: string | null;
+ definitionId?: string | null;
+ responsibleUserId?: string | null;
+ version?: SecretVersionSelector;
+ required?: boolean;
+ allowMissingOverride?: boolean;
+ },
+ context?: SecretConsumerContext,
+ ): Promise => {
+ const responsibleUserId = input.responsibleUserId ?? context?.responsibleUserId ?? null;
+ const optionalBinding = input.allowMissingOverride || input.required === false;
+ let definition: typeof userSecretDefinitions.$inferSelect;
+ try {
+ definition = await resolveUserSecretDefinition(companyId, input);
+ } catch (error) {
+ if (optionalBinding && error instanceof HttpError && error.status === 404) return null;
+ throw error;
+ }
+ if (definition.status !== "active") {
+ if (optionalBinding) return null;
+ throw unprocessable("User secret definition is not active");
+ }
+ if (!responsibleUserId?.trim()) {
+ if (optionalBinding) return null;
+ throw unprocessable("Responsible user is required for user secret resolution", {
+ code: "responsible_user_missing",
+ });
+ }
+ let declaration: typeof userSecretDeclarations.$inferSelect | null = null;
+ if (context?.configPath) {
+ declaration = await db
+ .select()
+ .from(userSecretDeclarations)
+ .where(and(
+ eq(userSecretDeclarations.companyId, companyId),
+ eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
+ eq(userSecretDeclarations.targetType, context.consumerType),
+ eq(userSecretDeclarations.targetId, context.consumerId),
+ eq(userSecretDeclarations.configPath, context.configPath),
+ ))
+ .then((rows) => rows[0] ?? null);
+ if (!declaration) {
+ if (optionalBinding) return null;
+ throw unprocessable(
+ `User secret is not declared for ${context.consumerType}:${context.consumerId} at ${context.configPath}`,
+ { code: "binding_missing" },
+ );
+ }
+ }
+ if (
+ Array.isArray(context?.allowedBindingIds) &&
+ (!declaration || !context.allowedBindingIds.includes(declaration.id))
+ ) {
+ throw unprocessable(
+ "User secret declaration is outside the active low-trust boundary",
+ { code: "binding_not_allowed" },
+ );
+ }
+ const secret = await getUserSecretValue({
+ companyId,
+ ownerUserId: responsibleUserId,
+ definitionId: definition.id,
+ });
+ if (!secret) {
+ if (optionalBinding) return null;
+ throw unprocessable("User secret value is not configured", {
+ code: "user_secret_missing",
+ definitionId: definition.id,
+ responsibleUserId,
+ });
+ }
+ const resolution = await resolveSecretValueInternal(
+ companyId,
+ secret.id,
+ input.version ?? "latest",
+ {
+ accessContext: context ? { ...context, responsibleUserId } : undefined,
+ allowUserSecretScope: true,
+ },
+ );
+ return {
+ ...resolution,
+ manifestEntry: {
+ ...resolution.manifestEntry,
+ bindingId: declaration?.id ?? resolution.manifestEntry.bindingId ?? null,
+ },
+ };
+ },
+
previewRemoteImport: async (
companyId: string,
input: {
@@ -1923,6 +2789,7 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
+ eq(companySecrets.scope, "company"),
eq(companySecrets.key, key),
ne(companySecrets.status, "deleted"),
))
@@ -2247,6 +3114,7 @@ export function secretService(db: Db) {
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, secret.companyId),
+ eq(companySecrets.scope, "company"),
eq(companySecrets.key, nextKey),
ne(companySecrets.status, "deleted"),
))
@@ -2472,12 +3340,32 @@ export function secretService(db: Db) {
configPath: string;
versionSelector: SecretVersionSelector;
}> = [];
+ const userRefs: Array<{
+ definitionKey: string;
+ configPath: string;
+ envKey: string;
+ versionSelector: SecretVersionSelector;
+ required: boolean;
+ allowMissingOverride: boolean;
+ }> = [];
const pathPrefix = target.pathPrefix ?? "env";
const bindingDb = options?.db ?? db;
for (const [key, rawBinding] of Object.entries(record)) {
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) continue;
const binding = canonicalizeBinding(parsed.data as EnvBinding);
+ if (binding.type === "user_secret_ref") {
+ await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb);
+ userRefs.push({
+ definitionKey: binding.key,
+ configPath: `${pathPrefix}.${key}`,
+ envKey: key,
+ versionSelector: binding.version,
+ required: binding.required,
+ allowMissingOverride: binding.allowMissingOverride,
+ });
+ continue;
+ }
if (binding.type !== "secret_ref") continue;
await assertSecretInCompany(companyId, binding.secretId, bindingDb);
refs.push({
@@ -2509,65 +3397,54 @@ export function secretService(db: Db) {
versionSelector: String(ref.versionSelector),
required: true,
})),
+ );
+ };
+
+ const writeUserDeclarations = async (targetDb: SecretBindingDb) => {
+ await targetDb
+ .delete(userSecretDeclarations)
+ .where(
+ and(
+ eq(userSecretDeclarations.companyId, companyId),
+ eq(userSecretDeclarations.targetType, target.targetType),
+ eq(userSecretDeclarations.targetId, target.targetId),
+ like(userSecretDeclarations.configPath, `${pathPrefix}.%`),
+ ),
+ );
+ if (userRefs.length === 0) return;
+ const definitions = new Map();
+ for (const ref of userRefs) {
+ const definition = await resolveUserSecretDefinition(companyId, { definitionKey: ref.definitionKey }, targetDb);
+ definitions.set(ref.definitionKey, definition.id);
+ }
+ await targetDb.insert(userSecretDeclarations).values(
+ userRefs.map((ref) => ({
+ companyId,
+ userSecretDefinitionId: definitions.get(ref.definitionKey)!,
+ targetType: target.targetType,
+ targetId: target.targetId,
+ configPath: ref.configPath,
+ envKey: ref.envKey,
+ versionSelector: String(ref.versionSelector),
+ required: ref.required,
+ allowMissingOverride: ref.allowMissingOverride,
+ })),
);
};
if (options?.db) {
await writeBindings(options.db);
+ await writeUserDeclarations(options.db);
} else {
- await db.transaction(async (tx) => writeBindings(tx));
+ await db.transaction(async (tx) => {
+ await writeBindings(tx);
+ await writeUserDeclarations(tx);
+ });
}
return refs;
},
- remove: async (secretId: string) => {
- const secret = await getById(secretId);
- if (!secret) return null;
- const versionRow = await getSecretVersion(secret.id, secret.latestVersion);
- const providerId = secret.provider as SecretProvider;
- const provider = getSecretProvider(providerId);
- if (secret.status !== "deleted") {
- await db
- .update(companySecrets)
- .set({
- key: `${secret.key}__deleted__${secret.id}`,
- name: `${secret.name}__deleted__${secret.id}`,
- status: "deleted",
- deletedAt: secret.deletedAt ?? new Date(),
- updatedAt: new Date(),
- })
- .where(eq(companySecrets.id, secretId));
- }
- const providerConfig = secret.providerConfigId
- ? await getProviderConfigById(secret.providerConfigId)
- : null;
- const providerRuntimeConfig =
- providerConfig && providerConfig.status !== "disabled" && providerConfig.status !== "coming_soon"
- ? toProviderVaultRuntimeConfig(providerConfig)
- : null;
- if (!secret.providerConfigId || providerRuntimeConfig) {
- try {
- await provider.deleteOrArchive({
- material: versionRow?.material as Record | undefined,
- externalRef: secret.externalRef,
- providerConfig: providerRuntimeConfig,
- context: {
- companyId: secret.companyId,
- secretKey: secret.key,
- secretName: secret.name,
- version: secret.latestVersion,
- },
- mode: "delete",
- });
- } catch (error) {
- if (!isSecretProviderClientError(error) || error.code !== "not_found") {
- throw error;
- }
- }
- }
- await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
- return secret;
- },
+ remove: removeSecretInternal,
normalizeAdapterConfigForPersistence: async (
companyId: string,
@@ -2620,7 +3497,7 @@ export function secretService(db: Db) {
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") {
resolved[key] = binding.value;
- } else {
+ } else if (binding.type === "secret_ref") {
const secretResolution = await resolveSecretValueInternal(
companyId,
binding.secretId,
@@ -2635,6 +3512,28 @@ export function secretService(db: Db) {
resolved[key] = secretResolution.value;
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
+ } else {
+ const secretResolution = await secretService(db).resolveUserSecretValue(
+ companyId,
+ {
+ definitionKey: binding.key,
+ version: binding.version,
+ required: binding.required,
+ allowMissingOverride: binding.allowMissingOverride,
+ },
+ context
+ ? {
+ ...context,
+ configPath: `env.${key}`,
+ responsibleUserId: context.responsibleUserId ?? null,
+ }
+ : undefined,
+ );
+ if (secretResolution) {
+ resolved[key] = secretResolution.value;
+ manifest.push(secretResolution.manifestEntry);
+ secretKeys.add(key);
+ }
}
}
return { env: resolved, secretKeys, manifest };
@@ -2659,7 +3558,16 @@ export function secretService(db: Db) {
if (binding.type !== "secret_ref") return [];
return [{ key, configPath: `env.${key}`, secretId: binding.secretId }];
});
- if (secretRefs.length === 0) return [];
+ const userSecretRefs = Object.entries(record).flatMap(([key, rawBinding]) => {
+ if (!ENV_KEY_RE.test(key)) return [];
+ const parsed = envBindingSchema.safeParse(rawBinding);
+ if (!parsed.success) return [];
+ const binding = canonicalizeBinding(parsed.data as EnvBinding);
+ if (binding.type !== "user_secret_ref") return [];
+ if (!binding.required || binding.allowMissingOverride) return [];
+ return [{ key, configPath: `env.${key}`, binding }];
+ });
+ if (secretRefs.length === 0 && userSecretRefs.length === 0) return [];
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
entry,
@@ -2674,7 +3582,6 @@ export function secretService(db: Db) {
const missingEntries = bindingChecks
.filter((check) => !check.found)
.map((check) => check.entry);
- if (missingEntries.length === 0) return [];
const secretRows = await Promise.all(
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
@@ -2684,14 +3591,115 @@ export function secretService(db: Db) {
);
const secretsById = new Map(secretRows);
- return missingEntries.map((entry) => ({
+ const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
+ bindingType: "secret_ref",
secretId: entry.secretId,
secretName: secretsById.get(entry.secretId)?.name ?? null,
}));
+
+ const missingUserSecretBindings: MissingRuntimeBinding[] = [];
+ for (const entry of userSecretRefs) {
+ let definition: typeof userSecretDefinitions.$inferSelect | null = null;
+ try {
+ definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key });
+ } catch {
+ missingUserSecretBindings.push(
+ missingUserSecretDefinitionRuntimeBinding(
+ entry,
+ context,
+ null,
+ "user_secret_definition_missing",
+ ),
+ );
+ continue;
+ }
+ if (definition.status !== "active") {
+ missingUserSecretBindings.push(
+ missingUserSecretDefinitionRuntimeBinding(
+ entry,
+ context,
+ definition,
+ "user_secret_definition_inactive",
+ ),
+ );
+ continue;
+ }
+
+ const declaration = await db
+ .select()
+ .from(userSecretDeclarations)
+ .where(and(
+ eq(userSecretDeclarations.companyId, companyId),
+ eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
+ eq(userSecretDeclarations.targetType, context.consumerType),
+ eq(userSecretDeclarations.targetId, context.consumerId),
+ eq(userSecretDeclarations.configPath, entry.configPath),
+ ))
+ .then((rows) => rows[0] ?? null);
+ if (!declaration) {
+ missingUserSecretBindings.push({
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: null,
+ secretName: null,
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: definition.key,
+ userSecretDefinitionName: definition.name,
+ responsibleUserId: context.responsibleUserId ?? null,
+ errorCode: "binding_missing",
+ });
+ continue;
+ }
+
+ if (!context.responsibleUserId?.trim()) {
+ missingUserSecretBindings.push({
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: null,
+ secretName: null,
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: definition.key,
+ userSecretDefinitionName: definition.name,
+ responsibleUserId: null,
+ errorCode: "responsible_user_missing",
+ });
+ continue;
+ }
+
+ const secret = await getUserSecretValue({
+ companyId,
+ ownerUserId: context.responsibleUserId,
+ definitionId: definition.id,
+ });
+ if (!secret || secret.status !== "active") {
+ missingUserSecretBindings.push({
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: secret?.id ?? null,
+ secretName: null,
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: definition.key,
+ userSecretDefinitionName: definition.name,
+ responsibleUserId: context.responsibleUserId,
+ errorCode: secret ? "secret_inactive" : "user_secret_missing",
+ });
+ }
+ }
+
+ return [...missingSecretBindings, ...missingUserSecretBindings];
},
collectMissingAdapterConfigRuntimeBindings: async (
@@ -2708,7 +3716,16 @@ export function secretService(db: Db) {
if (binding.type !== "secret_ref") return [];
return [{ key, configPath: key, secretId: binding.secretId }];
});
- if (secretRefs.length === 0) return [];
+ const userSecretRefs = secretFieldKeys.flatMap((key) => {
+ const parsed = envBindingSchema.safeParse(adapterConfig[key]);
+ if (!parsed.success) return [];
+ const binding = canonicalizeBinding(parsed.data as EnvBinding);
+ if (binding.type !== "user_secret_ref") return [];
+ if (!binding.required || binding.allowMissingOverride) return [];
+ return [{ key, configPath: key, binding }];
+ });
+ if (secretRefs.length === 0 && userSecretRefs.length === 0) return [];
+
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
entry,
found: await getBinding({
@@ -2722,7 +3739,6 @@ export function secretService(db: Db) {
const missingEntries = bindingChecks
.filter((check) => !check.found)
.map((check) => check.entry);
- if (missingEntries.length === 0) return [];
const secretRows = await Promise.all(
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
@@ -2732,14 +3748,115 @@ export function secretService(db: Db) {
);
const secretsById = new Map(secretRows);
- return missingEntries.map((entry) => ({
+ const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({
consumerType: context.consumerType,
consumerId: context.consumerId,
configPath: entry.configPath,
envKey: entry.key,
+ bindingType: "secret_ref",
secretId: entry.secretId,
secretName: secretsById.get(entry.secretId)?.name ?? null,
}));
+
+ const missingUserSecretBindings: MissingRuntimeBinding[] = [];
+ for (const entry of userSecretRefs) {
+ let definition: typeof userSecretDefinitions.$inferSelect | null = null;
+ try {
+ definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key });
+ } catch {
+ missingUserSecretBindings.push(
+ missingUserSecretDefinitionRuntimeBinding(
+ entry,
+ context,
+ null,
+ "user_secret_definition_missing",
+ ),
+ );
+ continue;
+ }
+ if (definition.status !== "active") {
+ missingUserSecretBindings.push(
+ missingUserSecretDefinitionRuntimeBinding(
+ entry,
+ context,
+ definition,
+ "user_secret_definition_inactive",
+ ),
+ );
+ continue;
+ }
+
+ const declaration = await db
+ .select()
+ .from(userSecretDeclarations)
+ .where(and(
+ eq(userSecretDeclarations.companyId, companyId),
+ eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
+ eq(userSecretDeclarations.targetType, context.consumerType),
+ eq(userSecretDeclarations.targetId, context.consumerId),
+ eq(userSecretDeclarations.configPath, entry.configPath),
+ ))
+ .then((rows) => rows[0] ?? null);
+ if (!declaration) {
+ missingUserSecretBindings.push({
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: null,
+ secretName: null,
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: definition.key,
+ userSecretDefinitionName: definition.name,
+ responsibleUserId: context.responsibleUserId ?? null,
+ errorCode: "binding_missing",
+ });
+ continue;
+ }
+
+ if (!context.responsibleUserId?.trim()) {
+ missingUserSecretBindings.push({
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: null,
+ secretName: null,
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: definition.key,
+ userSecretDefinitionName: definition.name,
+ responsibleUserId: null,
+ errorCode: "responsible_user_missing",
+ });
+ continue;
+ }
+
+ const secret = await getUserSecretValue({
+ companyId,
+ ownerUserId: context.responsibleUserId,
+ definitionId: definition.id,
+ });
+ if (!secret || secret.status !== "active") {
+ missingUserSecretBindings.push({
+ consumerType: context.consumerType,
+ consumerId: context.consumerId,
+ configPath: entry.configPath,
+ envKey: entry.key,
+ bindingType: "user_secret_ref",
+ secretId: secret?.id ?? null,
+ secretName: null,
+ userSecretDefinitionId: definition.id,
+ userSecretDefinitionKey: definition.key,
+ userSecretDefinitionName: definition.name,
+ responsibleUserId: context.responsibleUserId,
+ errorCode: secret ? "secret_inactive" : "user_secret_missing",
+ });
+ }
+ }
+
+ return [...missingSecretBindings, ...missingUserSecretBindings];
},
resolveAdapterConfigForRuntime: async (
@@ -2768,7 +3885,7 @@ export function secretService(db: Db) {
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") {
env[key] = binding.value;
- } else {
+ } else if (binding.type === "secret_ref") {
const secretResolution = await resolveSecretValueInternal(
companyId,
binding.secretId,
@@ -2783,6 +3900,28 @@ export function secretService(db: Db) {
env[key] = secretResolution.value;
manifest.push(secretResolution.manifestEntry);
secretKeys.add(key);
+ } else {
+ const secretResolution = await secretService(db).resolveUserSecretValue(
+ companyId,
+ {
+ definitionKey: binding.key,
+ version: binding.version,
+ required: binding.required,
+ allowMissingOverride: binding.allowMissingOverride,
+ },
+ context
+ ? {
+ ...context,
+ configPath: `env.${key}`,
+ responsibleUserId: context.responsibleUserId ?? null,
+ }
+ : undefined,
+ );
+ if (secretResolution) {
+ env[key] = secretResolution.value;
+ manifest.push(secretResolution.manifestEntry);
+ secretKeys.add(key);
+ }
}
}
resolved.env = env;
@@ -2794,6 +3933,30 @@ export function secretService(db: Db) {
if (!parsed.success) continue;
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") continue;
+ if (binding.type === "user_secret_ref") {
+ const secretResolution = await secretService(db).resolveUserSecretValue(
+ companyId,
+ {
+ definitionKey: binding.key,
+ version: binding.version,
+ required: binding.required,
+ allowMissingOverride: binding.allowMissingOverride,
+ },
+ context
+ ? {
+ ...context,
+ configPath: key,
+ responsibleUserId: context.responsibleUserId ?? null,
+ }
+ : undefined,
+ );
+ if (secretResolution) {
+ resolved[key] = secretResolution.value;
+ manifest.push(secretResolution.manifestEntry);
+ secretKeys.add(key);
+ }
+ continue;
+ }
const secretResolution = await resolveSecretValueInternal(
companyId,
binding.secretId,
diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts
index 4d04d1cc48..6629be3c5b 100644
--- a/server/src/types/express.d.ts
+++ b/server/src/types/express.d.ts
@@ -18,10 +18,16 @@ declare global {
membershipRole?: string | null;
status?: string;
}>;
+ onBehalfOfMemberships?: Array<{
+ companyId: string;
+ membershipRole?: string | null;
+ status?: string;
+ }>;
isInstanceAdmin?: boolean;
keyId?: string;
keyScope?: AgentApiKeyScope;
runId?: string;
+ onBehalfOfUserId?: string | null;
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
};
}
diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts
index eab54a3676..9e98810ee3 100644
--- a/tests/e2e/signoff-policy.spec.ts
+++ b/tests/e2e/signoff-policy.spec.ts
@@ -99,6 +99,12 @@ async function agentCheckoutAndPatch(
patchData: Record,
) {
const runId = await invokeHeartbeat(board, agent.agentId);
+ const directPatchRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
+ headers: { "X-Paperclip-Run-Id": runId },
+ data: patchData,
+ });
+ if (directPatchRes.ok()) return directPatchRes;
+
// Checkout (sets executionRunId so PATCH is allowed)
const checkoutRes = await agent.request.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
headers: { "X-Paperclip-Run-Id": runId },
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 4a9e971f7c..f11df9b259 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -39,6 +39,7 @@ import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CloudUpstream } from "./pages/CloudUpstream";
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab";
+import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab";
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
@@ -395,6 +396,7 @@ export function App() {
} />
} />
} />
+ } />
}>
} />
diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts
index e5eaf07012..b86f683d80 100644
--- a/ui/src/api/activity.ts
+++ b/ui/src/api/activity.ts
@@ -12,6 +12,7 @@ export interface RunForIssue {
finishedAt: string | null;
createdAt: string;
invocationSource: string;
+ responsibleUserId?: string | null;
errorCode?: string | null;
usageJson: Record | null;
resultJson: Record | null;
diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts
index f04705f3a8..d0bdca591b 100644
--- a/ui/src/api/secrets.ts
+++ b/ui/src/api/secrets.ts
@@ -12,6 +12,8 @@ import type {
SecretProviderConfigHealthResponse,
SecretProviderDescriptor,
SecretStatus,
+ UserSecretCoverageSummary,
+ UserSecretDefinition,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -20,6 +22,43 @@ export interface SecretUsageResponse {
bindings: CompanySecretUsageBinding[];
}
+/** One "My secrets" row: a company definition paired with the current user's own value (if set). */
+export interface MyUserSecretEntry {
+ definition: UserSecretDefinition;
+ secret: CompanySecret | null;
+}
+
+export interface CreateUserSecretDefinitionInput {
+ key: string;
+ name: string;
+ description?: string | null;
+ status?: Exclude;
+ provider?: SecretProvider;
+ managedMode?: SecretManagedMode;
+ providerConfigId?: string | null;
+ providerMetadata?: Record | null;
+ usageGuidance?: string | null;
+}
+
+export interface UpdateUserSecretDefinitionInput {
+ name?: string;
+ description?: string | null;
+ status?: SecretStatus;
+ providerConfigId?: string | null;
+ providerMetadata?: Record | null;
+ usageGuidance?: string | null;
+}
+
+/** Owner-supplied value for a user secret. Either `value` (managed) or `externalRef`. */
+export interface UpsertMyUserSecretInput {
+ definitionId?: string;
+ definitionKey?: string;
+ value?: string | null;
+ externalRef?: string | null;
+ providerVersionRef?: string | null;
+ providerConfigId?: string | null;
+}
+
export interface CreateSecretInput {
name: string;
key?: string;
@@ -147,6 +186,43 @@ export const secretsApi = {
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
usage: (id: string) => api.get(`/secrets/${id}/usage`),
accessEvents: (id: string) => api.get(`/secrets/${id}/access-events`),
+
+ // --- User-specific secrets ---------------------------------------------
+ // Admin: shared definitions each member fills in with their own value.
+ listUserSecretDefinitions: (companyId: string) =>
+ api.get(`/companies/${companyId}/user-secret-definitions`),
+ createUserSecretDefinition: (companyId: string, data: CreateUserSecretDefinitionInput) =>
+ api.post(`/companies/${companyId}/user-secret-definitions`, data),
+ updateUserSecretDefinition: (
+ companyId: string,
+ definitionId: string,
+ data: UpdateUserSecretDefinitionInput,
+ ) =>
+ api.patch(
+ `/companies/${companyId}/user-secret-definitions/${definitionId}`,
+ data,
+ ),
+ removeUserSecretDefinition: (companyId: string, definitionId: string) =>
+ api.delete<{ ok: true }>(`/companies/${companyId}/user-secret-definitions/${definitionId}`),
+ userSecretDefinitionCoverage: (companyId: string, definitionId: string) =>
+ api.get(
+ `/companies/${companyId}/user-secret-definitions/${definitionId}/coverage`,
+ ),
+
+ // Current user ("My secrets"): each definition paired with my own value.
+ listMyUserSecrets: (companyId: string) =>
+ api.get(`/companies/${companyId}/me/user-secrets`),
+ createMyUserSecret: (companyId: string, data: UpsertMyUserSecretInput) =>
+ api.post(`/companies/${companyId}/me/user-secrets`, data),
+ updateMyUserSecret: (
+ companyId: string,
+ secretId: string,
+ data: Partial & { status?: SecretStatus },
+ ) => api.patch(`/companies/${companyId}/me/user-secrets/${secretId}`, data),
+ rotateMyUserSecret: (companyId: string, secretId: string, data: UpsertMyUserSecretInput) =>
+ api.post(`/companies/${companyId}/me/user-secrets/${secretId}/rotate`, data),
+ removeMyUserSecret: (companyId: string, secretId: string) =>
+ api.delete<{ ok: true }>(`/companies/${companyId}/me/user-secrets/${secretId}`),
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
api.post(
`/companies/${companyId}/secrets/remote-import/preview`,
diff --git a/ui/src/components/ActivityCharts.test.tsx b/ui/src/components/ActivityCharts.test.tsx
index e9fe837161..1d17cd38df 100644
--- a/ui/src/components/ActivityCharts.test.tsx
+++ b/ui/src/components/ActivityCharts.test.tsx
@@ -38,6 +38,7 @@ function createRun(overrides: Partial = {}): HeartbeatRun {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
+ responsibleUserId: null,
invocationSource: "on_demand",
triggerDetail: "manual",
status: "succeeded",
diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx
index cf9989dfe0..ac507b7b88 100644
--- a/ui/src/components/AgentConfigForm.tsx
+++ b/ui/src/components/AgentConfigForm.tsx
@@ -221,6 +221,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
+ // User-secret definitions power the "User secret" env binding source. Requires
+ // secret-admin; non-admins simply get the free-text key fallback in the editor.
+ const { data: userSecretDefinitions = [] } = useQuery({
+ queryKey: selectedCompanyId
+ ? queryKeys.secrets.userDefinitions(selectedCompanyId)
+ : ["user-secret-definitions", "none"],
+ queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
+ enabled: Boolean(selectedCompanyId),
+ retry: false,
+ });
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@@ -1380,6 +1390,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)
}
secrets={availableSecrets}
+ userSecretDefinitions={userSecretDefinitions}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx
index 9026b05106..08ec0bea02 100644
--- a/ui/src/components/CommentThread.tsx
+++ b/ui/src/components/CommentThread.tsx
@@ -1062,14 +1062,14 @@ export function CommentThread({
{
- if (!option) return Assignee ;
+ if (!option) return Responsible ;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
diff --git a/ui/src/components/EnvVarEditor.test.tsx b/ui/src/components/EnvVarEditor.test.tsx
new file mode 100644
index 0000000000..fc0b8515f2
--- /dev/null
+++ b/ui/src/components/EnvVarEditor.test.tsx
@@ -0,0 +1,127 @@
+// @vitest-environment jsdom
+
+import { createRoot, type Root } from "react-dom/client";
+import { flushSync } from "react-dom";
+import type { CompanySecret, EnvBinding, UserSecretDefinition } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { EnvironmentVariablesEditor } from "./environment-variables-editor";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+const definition: UserSecretDefinition = {
+ id: "def-1",
+ companyId: "c1",
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GitHub token",
+ description: null,
+ status: "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ updatedByAgentId: null,
+ updatedByUserId: null,
+ deletedAt: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+};
+
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render(props: Partial>) {
+ root = createRoot(container);
+ return act(() => {
+ root.render(
+ ,
+ );
+ });
+}
+
+describe("EnvironmentVariablesEditor user secret binding", () => {
+ it("renders an existing user_secret_ref as a User secret row with the definition and requirement", async () => {
+ const value: Record = {
+ GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true },
+ };
+ await render({ value, userSecretDefinitions: [definition] });
+
+ const keyInput = container.querySelector('input[placeholder="KEY"]');
+ expect(keyInput?.value).toBe("GH_TOKEN");
+ // Radix Select triggers render the selected label as text.
+ expect(container.textContent).toContain("User secret");
+ expect(container.textContent).toContain("Personal GitHub token");
+ expect(container.textContent).toContain("Required");
+ });
+
+ it("explains user-secret bindings when a user secret row is present", async () => {
+ await render({
+ value: { GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true } },
+ userSecretDefinitions: [definition],
+ });
+ expect(container.textContent).toContain("Personal GitHub token");
+ expect(container.textContent).toContain("User secrets resolve from the user responsible for the run.");
+ });
+
+ it("keeps working for company secrets when no user definitions are provided", async () => {
+ const secret: CompanySecret = {
+ id: "sec-1",
+ companyId: "c1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
+ key: "api_key",
+ name: "API key",
+ provider: "local_encrypted",
+ status: "active",
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 2,
+ description: null,
+ lastResolvedAt: null,
+ lastRotatedAt: null,
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ const value: Record = {
+ API_KEY: { type: "secret_ref", secretId: "sec-1", version: "latest" },
+ };
+ await render({ value, secrets: [secret] });
+
+ const keyInput = container.querySelector('input[placeholder="KEY"]');
+ expect(keyInput?.value).toBe("API_KEY");
+ expect(container.textContent).toContain("API key");
+ });
+});
diff --git a/ui/src/components/FileTree.tsx b/ui/src/components/FileTree.tsx
index 201c79030c..338484a1ed 100644
--- a/ui/src/components/FileTree.tsx
+++ b/ui/src/components/FileTree.tsx
@@ -224,7 +224,7 @@ export const FRONTMATTER_FIELD_LABELS: Record = {
status: "Status",
description: "Description",
priority: "Priority",
- assignee: "Assignee",
+ assignee: "Responsible",
project: "Project",
recurring: "Recurring",
targetDate: "Target date",
diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx
index 77e395eb9e..e1decbebda 100644
--- a/ui/src/components/Identity.tsx
+++ b/ui/src/components/Identity.tsx
@@ -2,12 +2,14 @@ import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
type IdentitySize = "xs" | "sm" | "default" | "lg";
+type IdentityShape = "circle" | "square";
export interface IdentityProps {
name: string;
avatarUrl?: string | null;
initials?: string;
size?: IdentitySize;
+ shape?: IdentityShape;
className?: string;
}
@@ -24,7 +26,7 @@ const textSize: Record = {
lg: "text-sm",
};
-export function Identity({ name, avatarUrl, initials, size = "default", className }: IdentityProps) {
+export function Identity({ name, avatarUrl, initials, size = "default", shape = "circle", className }: IdentityProps) {
const displayInitials = initials ?? deriveInitials(name);
return (
@@ -32,7 +34,7 @@ export function Identity({ name, avatarUrl, initials, size = "default", classNam
className={cn("inline-flex min-w-0 gap-1.5 items-center", size === "xs" && "gap-1", size === "lg" && "gap-2", className)}
title={name}
>
-
+
{avatarUrl && }
{displayInitials}
diff --git a/ui/src/components/InlineEntitySelector.test.tsx b/ui/src/components/InlineEntitySelector.test.tsx
index 270adc54d8..8d4c3e1cce 100644
--- a/ui/src/components/InlineEntitySelector.test.tsx
+++ b/ui/src/components/InlineEntitySelector.test.tsx
@@ -46,10 +46,10 @@ describe("InlineEntitySelector", () => {
{ id: "agent:agent-1", label: "CodexCoder" },
{ id: "agent:agent-2", label: "DesignBot" },
]}
- placeholder="Assignee"
- noneLabel="No assignee"
- searchPlaceholder="Search assignees..."
- emptyMessage="No assignees found."
+ placeholder="Responsible"
+ noneLabel="No responsible"
+ searchPlaceholder="Search responsible..."
+ emptyMessage="No responsible found."
onChange={onChange}
/>,
);
@@ -62,7 +62,7 @@ describe("InlineEntitySelector", () => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- const searchInput = document.querySelector('input[placeholder="Search assignees..."]') as HTMLInputElement | null;
+ const searchInput = document.querySelector('input[placeholder="Search responsible..."]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
searchInput?.focus();
@@ -111,10 +111,10 @@ describe("InlineEntitySelector", () => {
{ id: "agent:agent-1", label: "CodexCoder" },
{ id: "agent:agent-2", label: "DesignBot" },
]}
- placeholder="Assignee"
- noneLabel="No assignee"
- searchPlaceholder="Search assignees..."
- emptyMessage="No assignees found."
+ placeholder="Responsible"
+ noneLabel="No responsible"
+ searchPlaceholder="Search responsible..."
+ emptyMessage="No responsible found."
onChange={vi.fn()}
/>,
);
@@ -127,7 +127,7 @@ describe("InlineEntitySelector", () => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- const searchInput = document.querySelector('input[placeholder="Search assignees..."]') as HTMLInputElement | null;
+ const searchInput = document.querySelector('input[placeholder="Search responsible..."]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
expect(document.activeElement).toBe(searchInput);
diff --git a/ui/src/components/IssueAssignedBacklogNotice.tsx b/ui/src/components/IssueAssignedBacklogNotice.tsx
index 8cbc0eb0df..fb52d69b77 100644
--- a/ui/src/components/IssueAssignedBacklogNotice.tsx
+++ b/ui/src/components/IssueAssignedBacklogNotice.tsx
@@ -20,7 +20,7 @@ export function IssueAssignedBacklogNotice({
if (issueStatus !== "backlog") return null;
if (!assigneeAgent && !assigneeUserId) return null;
- const assigneeLabel = assigneeAgent?.name ?? "the assignee";
+ const assigneeLabel = assigneeAgent?.name ?? "the responsible";
return (
{assigneeAgent ? (
- Comments still wake the assignee for questions or triage. Leave this parked only if the work is intentionally on hold.
+ Comments still wake the responsible for questions or triage. Leave this parked only if the work is intentionally on hold.
) : null}
{onResume ? (
diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx
index b24eed7c4e..0595cf2500 100644
--- a/ui/src/components/IssueBlockedNotice.tsx
+++ b/ui/src/components/IssueBlockedNotice.tsx
@@ -236,7 +236,7 @@ export function IssueBlockedNotice({
) : null}
- Corrective wake queued for {agentName ?? "the assignee"}
+ Corrective wake queued for {agentName ?? "the responsible"}
{successfulRunHandoff.detectedProgressSummary ? (
@@ -263,8 +263,8 @@ export function IssueBlockedNotice({
? stalledLeafBlockers.length > 1
? <>Work on this task is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.>
: <>Work on this task is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.>
- : <>Work on this task is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.>
- : <>Work on this task is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.>}
+ : <>Work on this task is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the responsible for questions or triage.>
+ : <>Work on this task is blocked until it is moved back to todo. Comments still wake the responsible for questions or triage.>}
{blockers.length > 0 ? (
diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx
index 21d6213be9..2ede85effd 100644
--- a/ui/src/components/IssueChatThread.test.tsx
+++ b/ui/src/components/IssueChatThread.test.tsx
@@ -273,7 +273,7 @@ function createExpiredRequestConfirmationInteraction(
resolvedAt: new Date("2026-04-06T12:05:00.000Z"),
payload: {
version: 1,
- prompt: "Approve the plan and let the assignee start implementation?",
+ prompt: "Approve the plan and let the responsible start implementation?",
acceptLabel: "Approve plan",
rejectLabel: "Request revisions",
},
@@ -2004,7 +2004,7 @@ describe("IssueChatThread", () => {
});
expect(container.textContent).toContain("Work on this task is blocked by the linked task");
- expect(container.textContent).toContain("Comments still wake the assignee for questions or triage");
+ expect(container.textContent).toContain("Comments still wake the responsible for questions or triage");
expect(container.textContent).toContain("PAP-1723");
expect(container.textContent).toContain("QA the install flow");
expect(container.querySelector('[data-issue-path-id="PAP-1723"]')).not.toBeNull();
@@ -2067,7 +2067,7 @@ describe("IssueChatThread", () => {
});
});
- it("shows paused assigned agent context above the composer", () => {
+ it("shows paused responsible agent context above the composer", () => {
const root = createRoot(container);
const pausedAgent = {
id: "agent-1",
@@ -3002,7 +3002,7 @@ describe("IssueChatThread", () => {
onAdd={async () => {}}
enableReassign
reassignOptions={[
- { id: "", label: "No assignee" },
+ { id: "", label: "No responsible" },
{ id: "agent:agent-1", label: "Agent 1" },
]}
currentAssigneeValue=""
@@ -3036,7 +3036,7 @@ describe("IssueChatThread", () => {
expect(appendMock).not.toHaveBeenCalled();
const dialog = document.querySelector('[data-testid="issue-chat-no-assignee-dialog"]');
expect(dialog).not.toBeNull();
- expect(dialog?.textContent).toContain("No assignee selected");
+ expect(dialog?.textContent).toContain("No responsible selected");
expect(dialog?.textContent).toContain("no agent will be woken");
const sendAnyway = document.querySelector(
@@ -3075,7 +3075,7 @@ describe("IssueChatThread", () => {
onAdd={async () => {}}
enableReassign
reassignOptions={[
- { id: "", label: "No assignee" },
+ { id: "", label: "No responsible" },
{ id: "agent:agent-1", label: "Agent 1" },
]}
currentAssigneeValue=""
@@ -3117,7 +3117,7 @@ describe("IssueChatThread", () => {
expect(appendMock).not.toHaveBeenCalled();
expect(document.querySelector('[data-testid="issue-chat-no-assignee-dialog"]')).toBeNull();
- // The composer keeps the draft so the user can pick an assignee and resend.
+ // The composer keeps the draft so the user can pick a responsible and resend.
const editorAfter = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(editorAfter?.value).toBe("Reply without assignee");
@@ -3140,7 +3140,7 @@ describe("IssueChatThread", () => {
onAdd={async () => {}}
enableReassign
reassignOptions={[
- { id: "", label: "No assignee" },
+ { id: "", label: "No responsible" },
{ id: "agent:agent-1", label: "Agent 1" },
]}
currentAssigneeValue="agent:agent-1"
@@ -3170,7 +3170,7 @@ describe("IssueChatThread", () => {
});
expect(appendMock).toHaveBeenCalledTimes(1);
- expect(document.body.textContent).not.toContain("No assignee selected");
+ expect(document.body.textContent).not.toContain("No responsible selected");
act(() => {
root.unmount();
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index ab9d88df6e..6aa61cf760 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -4050,14 +4050,14 @@ const IssueChatComposer = forwardRef
{
- if (!option) return Assignee ;
+ if (!option) return Responsible ;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
@@ -4102,10 +4102,10 @@ const IssueChatComposer = forwardRef
- No assignee selected
+ No responsible selected
This comment will be posted without an assignee, so no agent will be woken
- to act on it. Go back to pick an assignee, or send anyway.
+ to act on it. Go back to pick a responsible, or send anyway.
diff --git a/ui/src/components/IssueColumns.test.tsx b/ui/src/components/IssueColumns.test.tsx
index 9b01df2066..55a2325432 100644
--- a/ui/src/components/IssueColumns.test.tsx
+++ b/ui/src/components/IssueColumns.test.tsx
@@ -4,7 +4,8 @@ import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
-import { InboxIssueMetaLeading } from "./IssueColumns";
+import { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./IssueColumns";
+import { TooltipProvider } from "@/components/ui/tooltip";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -37,7 +38,7 @@ function renderLeading(element: React.ReactElement): string {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
- act(() => root!.render(element));
+ act(() => root!.render({element} ));
return container.textContent ?? "";
}
@@ -91,3 +92,107 @@ describe("InboxIssueMetaLeading live state", () => {
expect(text).not.toContain("live below");
});
});
+
+describe("InboxIssueTrailingColumns attribution", () => {
+ it("renders a kicked off by column for agent creators with square identity", () => {
+ const text = renderLeading(
+ ,
+ );
+
+ expect(text).toContain("CodexCoder");
+ expect(container?.querySelector('[data-shape="square"]')).not.toBeNull();
+ });
+
+ it("renders a kicked off by column for user creators", () => {
+ const text = renderLeading(
+ ,
+ );
+
+ expect(text).toContain("Riley Board");
+ expect(container?.querySelector('[data-shape="circle"]')).not.toBeNull();
+ });
+
+ it("attributes an agent-created issue to the transitive responsible user (circle, not agent square)", () => {
+ const text = renderLeading(
+ ,
+ );
+
+ // The responsible user wins over the creating agent.
+ expect(text).toContain("Morgan Product");
+ expect(container?.querySelector('[data-shape="circle"]')).not.toBeNull();
+ expect(container?.querySelector('[data-shape="square"]')).toBeNull();
+ });
+
+ it("surfaces the responsible user for a routine execution with no creator", () => {
+ const text = renderLeading(
+ ,
+ );
+
+ expect(text).toContain("Morgan Product");
+ expect(text).not.toContain("Unknown");
+ });
+});
diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx
index 9a9122ce65..2a921237e7 100644
--- a/ui/src/components/IssueColumns.tsx
+++ b/ui/src/components/IssueColumns.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
-import type { Issue } from "@paperclipai/shared";
+import { deriveOriginatingActor, type Issue } from "@paperclipai/shared";
import { Columns3 } from "lucide-react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Button } from "@/components/ui/button";
@@ -20,12 +20,13 @@ import { timeAgo } from "../lib/timeAgo";
import { Identity } from "./Identity";
import { StatusIcon } from "./StatusIcon";
-export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
+export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "kickedOffBy", "project", "workspace", "parent", "labels", "updated"];
const issueColumnLabels: Record = {
status: "Status",
id: "ID",
- assignee: "Assignee",
+ assignee: "Responsible",
+ kickedOffBy: "Kicked off by",
project: "Project",
workspace: "Workspace",
parent: "Parent task",
@@ -36,7 +37,8 @@ const issueColumnLabels: Record = {
const issueColumnDescriptions: Record = {
status: "Task state chip on the left edge.",
id: "Ticket identifier like PAP-1009.",
- assignee: "Assigned agent or board user.",
+ assignee: "Responsible agent or board user.",
+ kickedOffBy: "Board user or agent who created the task.",
project: "Linked project pill with its color.",
workspace: "Execution or project workspace used for the task.",
parent: "Parent task identifier and title.",
@@ -52,6 +54,7 @@ function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
return columns
.map((column) => {
if (column === "assignee") return "minmax(6rem, 8rem)";
+ if (column === "kickedOffBy") return "minmax(6rem, 8rem)";
if (column === "project") return "minmax(4.5rem, 7rem)";
if (column === "workspace") return "minmax(6rem, 9rem)";
if (column === "parent") return "minmax(3.5rem, 5.5rem)";
@@ -229,6 +232,10 @@ export function InboxIssueTrailingColumns({
assigneeName,
assigneeUserName,
assigneeUserAvatarUrl,
+ creatorAgentName,
+ creatorUserName,
+ creatorUserAvatarUrl,
+ viaAgentName,
currentUserId,
parentIdentifier,
parentTitle,
@@ -244,6 +251,10 @@ export function InboxIssueTrailingColumns({
assigneeName: string | null;
assigneeUserName?: string | null;
assigneeUserAvatarUrl?: string | null;
+ creatorAgentName?: string | null;
+ creatorUserName?: string | null;
+ creatorUserAvatarUrl?: string | null;
+ viaAgentName?: string | null;
currentUserId: string | null;
parentIdentifier: string | null;
parentTitle: string | null;
@@ -252,6 +263,9 @@ export function InboxIssueTrailingColumns({
}) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
+ const originatingActor = deriveOriginatingActor(issue);
+ const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null;
+ const creatorUserLabel = creatorUserName ?? formatAssigneeUserLabel(originatingUserId, currentUserId) ?? "User";
return (
@@ -296,6 +311,52 @@ export function InboxIssueTrailingColumns({
);
}
+ if (column === "kickedOffBy") {
+ if (originatingActor?.kind === "agent") {
+ const name = creatorAgentName ?? originatingActor.id.slice(0, 8);
+ return (
+
+
+
+
+
+
+ {name}
+
+ );
+ }
+
+ if (originatingActor?.kind === "user") {
+ const tooltipText = viaAgentName ? `${creatorUserLabel} · via ${viaAgentName}` : creatorUserLabel;
+ return (
+
+
+
+
+
+
+ {tooltipText}
+
+ );
+ }
+
+ return (
+
+ Unknown
+
+ );
+ }
+
if (column === "project") {
if (projectName) {
const accentColor = projectColor ?? "#64748b";
diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx
index 4884beea5d..bc3580d299 100644
--- a/ui/src/components/IssueDocumentsSection.test.tsx
+++ b/ui/src/components/IssueDocumentsSection.test.tsx
@@ -238,6 +238,7 @@ function createIssue(): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 807,
diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx
index 40de541730..dbd1468cf2 100644
--- a/ui/src/components/IssueFiltersPopover.tsx
+++ b/ui/src/components/IssueFiltersPopover.tsx
@@ -210,14 +210,14 @@ export function IssueFiltersPopover({
-
Assignee
+
Responsible
onChange({ assignees: toggleIssueFilterValue(state.assignees, "__unassigned") })}
/>
- No assignee
+ No responsible
{currentUserId ? (
diff --git a/ui/src/components/IssueLinkQuicklook.test.tsx b/ui/src/components/IssueLinkQuicklook.test.tsx
index 95cad42e69..a0f0012f16 100644
--- a/ui/src/components/IssueLinkQuicklook.test.tsx
+++ b/ui/src/components/IssueLinkQuicklook.test.tsx
@@ -34,6 +34,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
diff --git a/ui/src/components/IssueMonitorActivityCard.test.tsx b/ui/src/components/IssueMonitorActivityCard.test.tsx
index dcd4da6c50..7a32226b67 100644
--- a/ui/src/components/IssueMonitorActivityCard.test.tsx
+++ b/ui/src/components/IssueMonitorActivityCard.test.tsx
@@ -23,6 +23,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx
index bdd85637c4..73b5f8a968 100644
--- a/ui/src/components/IssueProperties.test.tsx
+++ b/ui/src/components/IssueProperties.test.tsx
@@ -43,6 +43,10 @@ const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
+const mockAccessApi = vi.hoisted(() => ({
+ listUserDirectory: vi.fn(),
+}));
+
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
@@ -73,6 +77,10 @@ vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
+vi.mock("../api/access", () => ({
+ accessApi: mockAccessApi,
+}));
+
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
@@ -96,7 +104,14 @@ vi.mock("../lib/recent-assignees", () => ({
}));
vi.mock("../lib/assignees", () => ({
- formatAssigneeUserLabel: () => "Me",
+ formatAssigneeUserLabel: (userId: string | null | undefined, currentUserId?: string | null, userLabelMap?: Map) => {
+ if (!userId) return null;
+ return userLabelMap?.get(userId) ?? (userId === currentUserId ? "You" : "User");
+ },
+ formatUserLabel: (userId: string | null | undefined, userLabelMap?: Map) => {
+ if (!userId) return null;
+ return userLabelMap?.get(userId) ?? "User";
+ },
}));
vi.mock("./StatusIcon", () => ({
@@ -110,7 +125,7 @@ vi.mock("./PriorityIcon", () => ({
}));
vi.mock("./Identity", () => ({
- Identity: ({ name }: { name: string }) => {name} ,
+ Identity: ({ name, shape }: { name: string; shape?: string }) => {name} ,
}));
vi.mock("./AgentIconPicker", () => ({
@@ -189,6 +204,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
@@ -425,6 +441,20 @@ describe("IssueProperties", () => {
mockIssuesApi.upsertWatchdog.mockResolvedValue({});
mockIssuesApi.deleteWatchdog.mockResolvedValue({ ok: true });
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
+ mockAccessApi.listUserDirectory.mockResolvedValue({
+ users: [
+ {
+ principalId: "user-1",
+ status: "active",
+ user: { id: "user-1", name: "Riley Board", email: "riley@example.com", image: null },
+ },
+ {
+ principalId: "user-2",
+ status: "active",
+ user: { id: "user-2", name: "Morgan Product", email: "morgan@example.com", image: null },
+ },
+ ],
+ });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
});
@@ -434,6 +464,133 @@ describe("IssueProperties", () => {
document.body.innerHTML = "";
});
+ it("shows assignee and originating without responsible wording", async () => {
+ mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "CodexCoder", status: "active", adapterType: "codex_local" }]);
+ const root = renderProperties(container, {
+ issue: createIssue({
+ assigneeAgentId: "agent-1",
+ createdByUserId: "user-1",
+ responsibleUserId: "user-2",
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ inline: true,
+ });
+ await flush();
+
+ await waitForAssertion(() => {
+ expect(container.textContent).toContain("Assignee");
+ expect(container.textContent).toContain("CodexCoder");
+ expect(container.textContent).toContain("Originating");
+ expect(container.textContent).toContain("Riley Board");
+ expect(container.textContent).not.toContain("Morgan Product");
+ expect(container.textContent).not.toContain("Responsible");
+ expect(container.textContent).not.toContain("Kicked off by");
+ expect(container.textContent).not.toContain("Created by");
+ expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
+ });
+
+ act(() => root.unmount());
+ });
+
+ it("shows originating without merged responsible wording when responsible is derived from the creator", async () => {
+ const root = renderProperties(container, {
+ issue: createIssue({
+ createdByUserId: "user-1",
+ responsibleUserId: null,
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ inline: true,
+ });
+ await flush();
+
+ await waitForAssertion(() => {
+ expect(container.textContent).toContain("Originating");
+ expect(container.textContent).toContain("Riley Board");
+ expect(container.textContent).not.toContain("Kicked off by");
+ expect(container.textContent).not.toContain("Kicked off by · responsible");
+ expect(container.textContent).not.toContain("(auto)");
+ expect(container.textContent).not.toContain("Created by");
+ });
+
+ act(() => root.unmount());
+ });
+
+ it("shows originating agent without responsible wording", async () => {
+ mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "CodexCoder", status: "active", adapterType: "codex_local" }]);
+ const root = renderProperties(container, {
+ issue: createIssue({
+ createdByAgentId: "agent-1",
+ createdByUserId: null,
+ responsibleUserId: null,
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ inline: true,
+ });
+ await flush();
+
+ await waitForAssertion(() => {
+ expect(container.textContent).toContain("Originating");
+ expect(container.textContent).toContain("CodexCoder");
+ expect(container.textContent).toContain("Assignee");
+ expect(container.textContent).toContain("Unassigned");
+ expect(container.textContent).not.toContain("Responsible");
+ expect(container.textContent).not.toContain("Kicked off by");
+ expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
+ });
+
+ act(() => root.unmount());
+ });
+
+ it("attributes an agent-created issue to the transitive responsible user with a via affordance", async () => {
+ mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "CodexCoder", status: "active", adapterType: "codex_local" }]);
+ const root = renderProperties(container, {
+ issue: createIssue({
+ createdByAgentId: "agent-1",
+ createdByUserId: null,
+ responsibleUserId: "user-2",
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ inline: true,
+ });
+ await flush();
+
+ await waitForAssertion(() => {
+ expect(container.textContent).toContain("Originating");
+ expect(container.textContent).toContain("Morgan Product");
+ expect(container.textContent).toContain("via CodexCoder");
+ expect(container.textContent).not.toContain("Responsible");
+ expect(container.textContent).not.toContain("Kicked off by");
+ });
+
+ act(() => root.unmount());
+ });
+
+ it("shows originating responsible user for a routine execution with no creator", async () => {
+ const root = renderProperties(container, {
+ issue: createIssue({
+ createdByAgentId: null,
+ createdByUserId: null,
+ responsibleUserId: "user-2",
+ }),
+ childIssues: [],
+ onUpdate: vi.fn(),
+ inline: true,
+ });
+ await flush();
+
+ await waitForAssertion(() => {
+ expect(container.textContent).toContain("Originating");
+ expect(container.textContent).toContain("Morgan Product");
+ expect(container.textContent).not.toContain("via ");
+ });
+
+ act(() => root.unmount());
+ });
+
it("groups the assignee picker and gates a live-run reassign behind an interrupt confirm", async () => {
const minimalAgent = (id: string, name: string) =>
({
@@ -1410,7 +1567,7 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
- it("hides model options when the issue uses the assignee default", async () => {
+ it("hides model options when the issue uses the responsible default", async () => {
mockAgentsApi.list.mockResolvedValue([
{
id: "agent-1",
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx
index 7b80cd145d..28a2ce6e87 100644
--- a/ui/src/components/IssueRow.test.tsx
+++ b/ui/src/components/IssueRow.test.tsx
@@ -43,6 +43,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx
index a2dc5b48ab..751b4fe789 100644
--- a/ui/src/components/IssueRunLedger.test.tsx
+++ b/ui/src/components/IssueRunLedger.test.tsx
@@ -93,6 +93,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
@@ -159,6 +160,7 @@ function renderLedger(props: Partial {
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(onWatchdogDecision).not.toHaveBeenCalled();
});
+
+ it("surfaces the responsible user a run acts on behalf of", () => {
+ renderLedger({
+ runs: [createRun({ runId: "run-obo-1", responsibleUserId: "user-2" })],
+ resolveUserLabel: (userId) => (userId === "user-2" ? "Ada Lovelace" : null),
+ });
+
+ const chip = container.querySelector('[data-testid="run-on-behalf-of"]');
+ expect(chip).not.toBeNull();
+ expect(chip?.textContent).toContain("on behalf of");
+ expect(chip?.textContent).toContain("Ada Lovelace");
+ });
+
+ it("omits the on-behalf-of chip when the run has no responsible user", () => {
+ renderLedger({
+ runs: [createRun({ runId: "run-obo-2", responsibleUserId: null })],
+ resolveUserLabel: () => "Ada Lovelace",
+ });
+
+ expect(container.querySelector('[data-testid="run-on-behalf-of"]')).toBeNull();
+ });
+
+ it("renders actionable copy for a responsible-user-unauthorized run failure", () => {
+ renderLedger({
+ runs: [
+ createRun({
+ runId: "run-denied-1",
+ status: "failed",
+ livenessState: "failed",
+ responsibleUserId: "user-2",
+ errorCode: "RESPONSIBLE_USER_UNAUTHORIZED",
+ }),
+ ],
+ resolveUserLabel: () => "Ada Lovelace",
+ });
+
+ const notice = container.querySelector('[data-testid="responsible-user-denial-notice"]');
+ expect(notice).not.toBeNull();
+ expect(notice?.getAttribute("data-denial-tone")).toBe("unauthorized");
+ expect(notice?.textContent).toContain("Ada Lovelace");
+ expect(notice?.textContent).toContain("Responsible user not authorized");
+ });
+
+ it("steers the responsible-user-unavailable failure toward marking work blocked", () => {
+ renderLedger({
+ runs: [
+ createRun({
+ runId: "run-denied-2",
+ status: "failed",
+ livenessState: "failed",
+ responsibleUserId: "user-3",
+ errorCode: "RESPONSIBLE_USER_UNAVAILABLE",
+ }),
+ ],
+ resolveUserLabel: () => "Grace Hopper",
+ });
+
+ const notice = container.querySelector('[data-testid="responsible-user-denial-notice"]');
+ expect(notice).not.toBeNull();
+ expect(notice?.getAttribute("data-denial-tone")).toBe("unavailable");
+ expect(notice?.textContent?.toLowerCase()).toContain("blocked");
+ });
+
+ it("does not render a denial notice for a generic agent failure", () => {
+ renderLedger({
+ runs: [
+ createRun({
+ runId: "run-denied-3",
+ status: "failed",
+ livenessState: "failed",
+ errorCode: "budget_blocked",
+ }),
+ ],
+ });
+
+ expect(container.querySelector('[data-testid="responsible-user-denial-notice"]')).toBeNull();
+ });
});
diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx
index 4746aea080..3b0b44d704 100644
--- a/ui/src/components/IssueRunLedger.tsx
+++ b/ui/src/components/IssueRunLedger.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState, type ReactNode } from "react";
import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared";
+import { isResponsibleUserDenialCode, responsibleUserLabel } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { accessApi, type CurrentBoardAccess } from "../api/access";
@@ -18,6 +19,7 @@ import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"
import { describeRunRetryState } from "../lib/runRetryState";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { SourceResolvedFoldBadge } from "./SourceResolvedFoldBadge";
+import { ResponsibleUserDenialNotice } from "./ResponsibleUserDenialNotice";
type IssueRunLedgerProps = {
issueId: string;
@@ -28,6 +30,7 @@ type IssueRunLedgerProps = {
hasLiveRuns: boolean;
activityEvents?: ActivityEvent[];
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
+ resolveUserLabel?: (userId: string) => string | null | undefined;
};
type IssueRunLedgerContentProps = {
@@ -39,6 +42,7 @@ type IssueRunLedgerContentProps = {
agentMap: ReadonlyMap>;
activityEvents?: ActivityEvent[];
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
+ resolveUserLabel?: (userId: string) => string | null | undefined;
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
canRecordWatchdogDecisions?: boolean;
watchdogDecisionError?: string | null;
@@ -407,6 +411,7 @@ export function IssueRunLedger({
hasLiveRuns,
activityEvents,
renderActivityEvent,
+ resolveUserLabel,
}: IssueRunLedgerProps) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
@@ -469,6 +474,7 @@ export function IssueRunLedger({
agentMap={agentMap}
activityEvents={activityEvents}
renderActivityEvent={renderActivityEvent}
+ resolveUserLabel={resolveUserLabel}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
watchdogDecisionError={watchdogDecisionError}
@@ -486,6 +492,7 @@ export function IssueRunLedgerContent({
agentMap,
activityEvents,
renderActivityEvent,
+ resolveUserLabel,
pendingWatchdogDecision,
canRecordWatchdogDecisions = true,
watchdogDecisionError,
@@ -695,6 +702,10 @@ export function IssueRunLedgerContent({
const continuation = continuationLabel(run);
const retryState = describeRunRetryState(run);
const agentName = compactAgentName(run, agentMap);
+ const onBehalfOfLabel = run.responsibleUserId
+ ? responsibleUserLabel(resolveUserLabel?.(run.responsibleUserId))
+ : null;
+ const denialCode = isResponsibleUserDenialCode(run.errorCode) ? run.errorCode : null;
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
return (
by {agentName}
+ {onBehalfOfLabel ? (
+
+ on behalf of {onBehalfOfLabel}
+
+ ) : null}
{statusLabel(run.status)}
@@ -833,6 +853,13 @@ export function IssueRunLedgerContent({
) : null}
+ {denialCode ? (
+
+ ) : null}
+
{run.nextAction ? (
Next action:
diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx
index b26f2bb89e..22adf75344 100644
--- a/ui/src/components/IssueThreadInteractionCard.tsx
+++ b/ui/src/components/IssueThreadInteractionCard.tsx
@@ -347,7 +347,7 @@ function TaskTreeNode({
{hasMetadata ? (
{hasExplicitAssignee ? (
-
+
) : null}
{node.task.billingCode ? (
@@ -1947,7 +1947,7 @@ export function IssueThreadInteractionCard({
{interaction.continuationPolicy === "wake_assignee_on_accept"
? "Wakes on confirm"
- : "Wakes assignee"}
+ : "Wakes responsible"}
) : null}
diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx
index a789f20354..35ee6c0bf1 100644
--- a/ui/src/components/IssueWorkspaceCard.test.tsx
+++ b/ui/src/components/IssueWorkspaceCard.test.tsx
@@ -92,6 +92,7 @@ function createIssue(overrides: Partial
= {}): Issue {
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 81,
diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx
index 3562fbef40..1be7d87ff8 100644
--- a/ui/src/components/IssuesList.test.tsx
+++ b/ui/src/components/IssuesList.test.tsx
@@ -176,6 +176,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx
index c075d2d217..4c10714e93 100644
--- a/ui/src/components/IssuesList.tsx
+++ b/ui/src/components/IssuesList.tsx
@@ -76,7 +76,7 @@ import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import { workflowSort } from "../lib/workflow-sort";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
-import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
+import { deriveOriginatingActor, ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
@@ -1580,7 +1580,7 @@ export function IssuesList({
{([
["status", "Status"],
["priority", "Priority"],
- ["assignee", "Assignee"],
+ ["assignee", "Responsible"],
["project", "Project"],
["workspace", "Workspace"],
["parent", "Parent Task"],
@@ -1705,6 +1705,10 @@ export function IssuesList({
currentUserId,
companyUserLabelMap,
) ?? assigneeUserProfile?.label ?? null;
+ const originatingActor = deriveOriginatingActor(issue);
+ const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null;
+ const originatingViaAgentId =
+ originatingActor?.kind === "user" ? originatingActor.viaAgentId ?? null : null;
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
e.preventDefault();
e.stopPropagation();
@@ -1883,6 +1887,10 @@ export function IssuesList({
assigneeName={agentName(issue.assigneeAgentId)}
assigneeUserName={assigneeUserLabel}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
+ creatorAgentName={agentName(issue.createdByAgentId)}
+ creatorUserName={originatingUserId ? (companyUserProfileMap.get(originatingUserId)?.label ?? null) : null}
+ creatorUserAvatarUrl={originatingUserId ? (companyUserProfileMap.get(originatingUserId)?.image ?? null) : null}
+ viaAgentName={originatingViaAgentId ? agentName(originatingViaAgentId) : null}
currentUserId={currentUserId}
parentIdentifier={parentIssue?.identifier ?? null}
parentTitle={parentIssue?.title ?? null}
@@ -1900,7 +1908,7 @@ export function IssuesList({
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
-
+
) : issue.assigneeUserId ? (
setAssigneeSearch(e.target.value)}
autoFocus
@@ -1943,7 +1951,7 @@ export function IssuesList({
assignIssue(issue.id, null, null);
}}
>
- No assignee
+ No responsible
{currentUserId && (
({
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
+const mockMissingUserSecretsBannerRender = vi.hoisted(() => vi.fn());
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
@@ -116,6 +117,20 @@ vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
+vi.mock("../pages/secrets/MissingUserSecretsBanner", async () => {
+ const React = await import("react");
+ return {
+ MissingUserSecretsBanner: (props: { definitionKeys?: string[] }) => {
+ mockMissingUserSecretsBannerRender(props);
+ return React.createElement(
+ "div",
+ { "data-testid": "missing-user-secrets-banner" },
+ props.definitionKeys?.join(",") ?? "",
+ );
+ },
+ };
+});
+
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
@@ -337,6 +352,7 @@ describe("NewIssueDialog", () => {
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/uploads/asset.png" });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
+ mockMissingUserSecretsBannerRender.mockReset();
localStorage.clear();
mockIssuesApi.create.mockResolvedValue({
id: "issue-2",
@@ -457,6 +473,64 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
+ it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => {
+ const { root } = renderDialog(container);
+ await flush();
+
+ expect(mockMissingUserSecretsBannerRender).not.toHaveBeenCalled();
+
+ act(() => root.unmount());
+ });
+
+ it("scopes user-secret warnings to selected runnable agent and project env bindings", async () => {
+ dialogState.newIssueDefaults = {
+ title: "Run with scoped secrets",
+ assigneeAgentId: "agent-1",
+ projectId: "project-1",
+ };
+ mockAgentsApi.list.mockResolvedValue([
+ {
+ id: "agent-1",
+ name: "CodexCoder",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {
+ env: {
+ AGENT_TOKEN: { type: "user_secret_ref", key: "agent_token", required: true },
+ OPTIONAL_TOKEN: { type: "user_secret_ref", key: "optional_token", required: false },
+ },
+ },
+ runtimeConfig: {},
+ permissions: {},
+ },
+ ]);
+ mockProjectsApi.list.mockResolvedValue([
+ {
+ id: "project-1",
+ name: "Alpha",
+ description: null,
+ archivedAt: null,
+ color: "#445566",
+ env: {
+ PROJECT_TOKEN: { type: "user_secret_ref", key: "project_token", required: true },
+ },
+ },
+ ]);
+
+ const { root } = renderDialog(container);
+ await waitForAssertion(() => {
+ expect(mockMissingUserSecretsBannerRender).toHaveBeenCalledWith(
+ expect.objectContaining({
+ definitionKeys: ["agent_token", "project_token"],
+ }),
+ );
+ });
+
+ expect(container.textContent).toContain("agent_token,project_token");
+
+ act(() => root.unmount());
+ });
+
it("restores the planning mode from dialog defaults", async () => {
dialogState.newIssueDefaults = {
title: "Planned from defaults",
@@ -1161,7 +1235,7 @@ describe("NewIssueDialog", () => {
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
expect(workModeOption("planning")?.textContent).toContain("Plan mode");
- expect(statusOptionIconClass("Todo", "Executable — assignee will be woken")).toContain("text-amber-600");
+ expect(statusOptionIconClass("Todo", "Executable - assignee will be woken")).toContain("text-amber-600");
expect(statusOptionIconClass("In Progress")).toContain("text-blue-600");
act(() => root.unmount());
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx
index bcbda2d457..60cbd4cfed 100644
--- a/ui/src/components/NewIssueDialog.tsx
+++ b/ui/src/components/NewIssueDialog.tsx
@@ -1,12 +1,13 @@
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import type { IssueWorkMode } from "@paperclipai/shared";
+import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared";
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
+import { MissingUserSecretsBanner } from "../pages/secrets/MissingUserSecretsBanner";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
@@ -226,13 +227,13 @@ function buildStatusOptions(): ReadonlyArray<{ value: string; label: string; col
value: "backlog",
label: "Backlog",
color: palette.backlog ?? issueStatusTextDefault,
- description: "Parked — assignee will not be woken",
+ description: "Parked - assignee will not be woken",
},
{
value: "todo",
label: "Todo",
color: palette.todo ?? issueStatusTextDefault,
- description: "Executable — assignee will be woken",
+ description: "Executable - assignee will be woken",
},
{ value: "in_progress", label: "In Progress", color: palette.in_progress ?? issueStatusTextDefault },
{ value: "in_review", label: "In Review", color: palette.in_review ?? issueStatusTextDefault },
@@ -240,6 +241,34 @@ function buildStatusOptions(): ReadonlyArray<{ value: string; label: string; col
];
}
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function isRequiredUserSecretBinding(value: unknown): value is Extract {
+ return isRecord(value)
+ && value.type === "user_secret_ref"
+ && typeof value.key === "string"
+ && value.key.trim().length > 0
+ && value.required !== false
+ && value.allowMissingOverride !== true;
+}
+
+function collectRequiredUserSecretKeysFromEnv(env: AgentEnvConfig | Record | null | undefined): string[] {
+ if (!isRecord(env)) return [];
+ return Object.values(env).flatMap((binding) =>
+ isRequiredUserSecretBinding(binding) ? [binding.key.trim()] : [],
+ );
+}
+
+function uniqueRequiredUserSecretKeys(inputs: Array | null | undefined>): string[] {
+ return [...new Set(inputs.flatMap(collectRequiredUserSecretKeysFromEnv))];
+}
+
+function shouldWarnAboutRunUserSecrets(status: string, assigneeAgentId: string | null | undefined) {
+ return Boolean(assigneeAgentId) && (status === "todo" || status === "in_progress");
+}
+
const priorities = [
{ value: "critical", label: "Critical", icon: AlertTriangle, color: priorityColor.critical ?? priorityColorDefault },
{ value: "high", label: "High", icon: ArrowUp, color: priorityColor.high ?? priorityColorDefault },
@@ -1088,6 +1117,16 @@ export function NewIssueDialog() {
: null;
const currentAssigneeLowTrust = getTrustPreset(currentAssignee?.permissions) === "low_trust_review";
const currentProject = orderedProjects.find((project) => project.id === projectId);
+ const neededUserSecretKeys = useMemo(
+ () => {
+ if (!shouldWarnAboutRunUserSecrets(status, selectedAssigneeAgentId)) return [];
+ return uniqueRequiredUserSecretKeys([
+ isRecord(currentAssignee?.adapterConfig) ? currentAssignee.adapterConfig.env as Record : null,
+ currentProject?.env ?? null,
+ ]);
+ },
+ [currentAssignee?.adapterConfig, currentProject?.env, selectedAssigneeAgentId, status],
+ );
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
@@ -1354,6 +1393,17 @@ export function NewIssueDialog() {
/>
+ {effectiveCompanyId ? (
+
+ {neededUserSecretKeys.length > 0 ? (
+
+ ) : null}
+
+ ) : null}
+
@@ -2161,7 +2211,7 @@ export function NewIssueDialog() {
>
- Assigning implies executable intent — leave status as Backlog only to deliberately park this. The assignee will not be woken until status moves to Todo or In Progress .
+ Assigning implies executable intent - leave status as Backlog only to deliberately park this. The assignee will not be woken until status moves to Todo or In Progress .
) : null}
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx
index e1bb32ca4a..2d895a4c38 100644
--- a/ui/src/components/ProjectProperties.tsx
+++ b/ui/src/components/ProjectProperties.tsx
@@ -256,6 +256,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
+ const { data: userSecretDefinitions = [] } = useQuery({
+ queryKey: selectedCompanyId
+ ? queryKeys.secrets.userDefinitions(selectedCompanyId)
+ : ["user-secret-definitions", "none"],
+ queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
+ enabled: Boolean(selectedCompanyId),
+ retry: false,
+ });
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
@@ -626,6 +634,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{
const created = await createSecret.mutateAsync({ name, value });
return created;
diff --git a/ui/src/components/ResponsibleUserDenialNotice.test.tsx b/ui/src/components/ResponsibleUserDenialNotice.test.tsx
new file mode 100644
index 0000000000..60e4c4b782
--- /dev/null
+++ b/ui/src/components/ResponsibleUserDenialNotice.test.tsx
@@ -0,0 +1,52 @@
+// @vitest-environment node
+
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { ResponsibleUserDenialNotice } from "./ResponsibleUserDenialNotice";
+
+describe("ResponsibleUserDenialNotice", () => {
+ it("renders unauthorized copy that names the responsible user", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Responsible user not authorized");
+ expect(html).toContain("Ada Lovelace");
+ expect(html).toContain('data-denial-code="RESPONSIBLE_USER_UNAUTHORIZED"');
+ expect(html).toContain('data-denial-tone="unauthorized"');
+ });
+
+ it("renders unavailable copy steering toward marking work blocked", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Responsible user unavailable");
+ expect(html).toContain("Grace Hopper");
+ expect(html).toContain('data-denial-tone="unavailable"');
+ expect(html.toLowerCase()).toContain("blocked");
+ });
+
+ it("falls back to generic phrasing when the user name is unknown", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("the responsible user");
+ });
+
+ it("never uses the word impersonate", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html.toLowerCase()).not.toContain("impersonate");
+ });
+});
diff --git a/ui/src/components/ResponsibleUserDenialNotice.tsx b/ui/src/components/ResponsibleUserDenialNotice.tsx
new file mode 100644
index 0000000000..c0441a9268
--- /dev/null
+++ b/ui/src/components/ResponsibleUserDenialNotice.tsx
@@ -0,0 +1,59 @@
+import { ShieldX, UserX } from "lucide-react";
+import {
+ describeResponsibleUserDenial,
+ type ResponsibleUserDenialCode,
+} from "@paperclipai/shared";
+import { cn } from "../lib/utils";
+
+/**
+ * Renders actionable copy for a responsible-user ("on behalf of") authorization
+ * denial. Distinct from a plain agent-lacks-permission failure: here the agent
+ * may be allowed, but the human the run acts for is not (or is unavailable).
+ *
+ * Copy comes from the shared `describeResponsibleUserDenial` contract so every
+ * surface stays consistent. Callers should only render this when the failure
+ * code is one of the responsible-user denial codes; other denials keep their
+ * existing generic error copy.
+ */
+export function ResponsibleUserDenialNotice({
+ code,
+ userName,
+ className,
+}: {
+ code: ResponsibleUserDenialCode;
+ userName?: string | null;
+ className?: string;
+}) {
+ const copy = describeResponsibleUserDenial(code, { userName });
+ const isUnavailable = copy.tone === "unavailable";
+ const Icon = isUnavailable ? UserX : ShieldX;
+
+ const tone = isUnavailable
+ ? "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
+ : "border-red-300/70 bg-red-50/90 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100";
+ const iconTone = isUnavailable
+ ? "text-amber-600 dark:text-amber-300"
+ : "text-red-600 dark:text-red-300";
+ const actionTone = isUnavailable
+ ? "text-amber-800 dark:text-amber-200"
+ : "text-red-800 dark:text-red-200";
+
+ return (
+
+
+
+
+
{copy.title}
+
{copy.description}
+
{copy.recommendedAction}
+
+
+
+ );
+}
diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx
index e55fb3e7c9..f5f83c8806 100644
--- a/ui/src/components/RoutineHistoryTab.test.tsx
+++ b/ui/src/components/RoutineHistoryTab.test.tsx
@@ -89,6 +89,7 @@ function snapshotV1(overrides?: Partial):
projectId: null,
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Daily standup digest",
description: "Summarize standup notes",
assigneeAgentId: null,
@@ -130,6 +131,7 @@ function createRoutine(overrides: Partial = {}): Routine {
projectId: null,
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Daily standup digest",
description: "Summarize standup notes",
assigneeAgentId: null,
@@ -346,6 +348,9 @@ describe("RoutineHistoryTab", () => {
{
id: "secret-1",
companyId: "company-1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "gh_token",
name: "github-bot",
provider: "local_encrypted",
@@ -408,6 +413,9 @@ describe("RoutineHistoryTab", () => {
{
id: "secret-1",
companyId: "company-1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "old_token",
name: "old-token",
provider: "local_encrypted",
@@ -429,6 +437,9 @@ describe("RoutineHistoryTab", () => {
{
id: "secret-2",
companyId: "company-1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "new_token",
name: "new-token",
provider: "local_encrypted",
diff --git a/ui/src/components/StageSecretsPanel.tsx b/ui/src/components/StageSecretsPanel.tsx
index c34ee918db..5ba0846395 100644
--- a/ui/src/components/StageSecretsPanel.tsx
+++ b/ui/src/components/StageSecretsPanel.tsx
@@ -59,7 +59,7 @@ export function StageSecretsPanel({
);
}
- const displayName = agentName?.trim() || "the assigned agent";
+ const displayName = agentName?.trim() || "the responsible agent";
return (
diff --git a/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx b/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx
index 8518ae86cf..9baa43f34f 100644
--- a/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx
+++ b/ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx
@@ -37,6 +37,9 @@ function makeSecret(id: string, overrides: Partial = {}): Company
return {
id,
companyId: "co",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: id,
name: id.toUpperCase(),
provider: "local_encrypted",
@@ -692,8 +695,8 @@ describe("EnvironmentVariablesEditor", () => {
).toBeTruthy();
});
- it("opens the store popover from the source dropdown Text→Secret (with a value) and keeps it open (PAP-12478)", () => {
- // Regression: switching a *non-empty* Text row to "Secret reference" via the
+ it("opens the store popover from the source dropdown Text→Company secret (with a value) and keeps it open (PAP-12478)", () => {
+ // Regression: switching a *non-empty* Text row to "Company secret" via the
// in-field Value source dropdown must open the anchored store-as-secret
// popover (§6.3) — value preserved, not discarded. This is the same nested
// DropdownMenu→Popover open-while-closing race as the ⋯ and picker paths
@@ -714,9 +717,9 @@ describe("EnvironmentVariablesEditor", () => {
pointerClick(sourceButton!);
settleFakeTimers();
const secretItem = [...document.querySelectorAll('[role="menuitem"]')].find((el) =>
- el.textContent?.includes("Secret reference"),
+ el.textContent?.includes("Company secret"),
);
- expect(secretItem, "Secret reference menu item should be present").toBeTruthy();
+ expect(secretItem, "Company secret menu item should be present").toBeTruthy();
pointerClick(secretItem!);
settleFakeTimers();
// The store popover is open (heading rendered) and stays open — it must not
diff --git a/ui/src/components/environment-variables-editor/Row.tsx b/ui/src/components/environment-variables-editor/Row.tsx
index 8489c0d8ae..f151e297ba 100644
--- a/ui/src/components/environment-variables-editor/Row.tsx
+++ b/ui/src/components/environment-variables-editor/Row.tsx
@@ -5,9 +5,10 @@ import {
MoreHorizontal,
ShieldAlert,
Type as TypeIcon,
+ UserRound,
X,
} from "lucide-react";
-import type { CompanySecret } from "@paperclipai/shared";
+import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
@@ -22,10 +23,12 @@ import { CreateSecretPopover, ConvertToSecretPopover } from "./CreateSecretPopov
import { isSensitiveEnv } from "./sensitive";
import {
computeRowHealth,
+ computeUserSecretRowHealth,
planSourceSwitch,
secretNameFromKey,
type EnvRow,
type NameIssue,
+ type RowSource,
} from "./model";
const nameInputClass =
@@ -44,6 +47,7 @@ export interface EnvironmentVariableRowProps {
row: EnvRow;
isLast: boolean;
secrets: readonly CompanySecret[];
+ userSecretDefinitions?: readonly UserSecretDefinition[];
recentlyUsedSecrets?: readonly CompanySecret[];
disabled?: boolean;
nameIssue: NameIssue | null;
@@ -67,6 +71,7 @@ export function EnvironmentVariableRow({
row,
isLast,
secrets,
+ userSecretDefinitions,
recentlyUsedSecrets,
disabled,
nameIssue,
@@ -89,8 +94,9 @@ export function EnvironmentVariableRow({
const [versionOpen, setVersionOpen] = useState(false);
const [undoPrev, setUndoPrev] = useState(null);
- const health = computeRowHealth(row, secrets);
+ const health = computeRowHealth(row, secrets) ?? computeUserSecretRowHealth(row, userSecretDefinitions);
const boundSecret = row.source === "secret" ? secrets.find((s) => s.id === row.secretId) ?? null : null;
+ const userSecretsEnabled = (userSecretDefinitions?.length ?? 0) > 0;
const sensitive =
row.source === "text" && !row.sensitiveDismissed && isSensitiveEnv(row.name, row.textValue);
@@ -101,9 +107,11 @@ export function EnvironmentVariableRow({
nameInputRef.current?.focus();
} else if (row.source === "text") {
valueInputRef.current?.focus();
- } else {
+ } else if (row.source === "secret") {
// Focusing the combobox trigger opens SearchableSelect (non-pointer focus).
valueCellRef.current?.querySelector("[role=combobox]")?.focus();
+ } else {
+ valueCellRef.current?.querySelector("select,input")?.focus();
}
onFocusConsumed();
}, [focusRequest, onFocusConsumed, row.source]);
@@ -115,7 +123,14 @@ export function EnvironmentVariableRow({
return () => window.clearTimeout(handle);
}, [undoPrev]);
- function switchSource(next: "text" | "secret") {
+ function switchSource(next: RowSource) {
+ if (next === "user_secret") {
+ if (row.source === "user_secret") return;
+ onPatch({ source: "user_secret", secretId: "", version: "latest" });
+ window.setTimeout(() => valueCellRef.current?.querySelector("select,input")?.focus(), 0);
+ return;
+ }
+
const plan = planSourceSwitch(row, next);
switch (plan.kind) {
case "noop":
@@ -133,7 +148,7 @@ export function EnvironmentVariableRow({
return;
}
case "to-secret":
- onPatch({ source: "secret" });
+ onPatch({ source: "secret", userSecretKey: "", required: true });
// Auto-open the picker.
window.setTimeout(() => {
valueCellRef.current?.querySelector("[role=combobox]")?.focus();
@@ -141,7 +156,7 @@ export function EnvironmentVariableRow({
return;
case "to-text":
if (plan.undoFrom) setUndoPrev(plan.undoFrom);
- onPatch({ source: "text", secretId: "", version: "latest" });
+ onPatch({ source: "text", secretId: "", userSecretKey: "", required: true, version: "latest" });
window.setTimeout(() => valueInputRef.current?.focus(), 0);
return;
}
@@ -149,7 +164,14 @@ export function EnvironmentVariableRow({
async function submitSecretPopover(name: string, value: string) {
const created = await onCreateSecret(name, value);
- onPatch({ source: "secret", secretId: created.id, version: "latest", textValue: "" });
+ onPatch({
+ source: "secret",
+ secretId: created.id,
+ userSecretKey: "",
+ required: true,
+ version: "latest",
+ textValue: "",
+ });
onToast(`Secret ${created.name} created`);
setSecretPopover(null);
}
@@ -160,7 +182,12 @@ export function EnvironmentVariableRow({
window.setTimeout(() => setSecretPopover({ mode: "store", name, value: textValue }), 0);
}
- const sourceLabel = row.source === "text" ? "Text value" : "Secret reference";
+ const sourceLabel =
+ row.source === "text"
+ ? "Text value"
+ : row.source === "secret"
+ ? "Company secret reference"
+ : "User secret reference";
const nameErrorId = `${row.id}-name-error`;
const healthId = `${row.id}-health`;
const isDirty = dirtyFields.name || dirtyFields.value;
@@ -205,7 +232,7 @@ export function EnvironmentVariableRow({
if (event.key === "Enter") {
event.preventDefault();
if (row.source === "text") valueInputRef.current?.focus();
- else valueCellRef.current?.querySelector("[role=combobox]")?.focus();
+ else valueCellRef.current?.querySelector("[role=combobox],select,input")?.focus();
}
}}
/>
@@ -240,8 +267,10 @@ export function EnvironmentVariableRow({
>
{row.source === "text" ? (
- ) : (
+ ) : row.source === "secret" ? (
+ ) : (
+
)}
@@ -255,9 +284,15 @@ export function EnvironmentVariableRow({
Store the value inline as plain text.
switchSource("secret")}>
- Secret reference
+ Company secret
Resolve a stored company secret at run start.
+ switchSource("user_secret")}>
+ User secret
+
+ Resolve the responsible user's own value at run start.
+
+
@@ -305,7 +340,7 @@ export function EnvironmentVariableRow({
) : null}
>
- ) : (
+ ) : row.source === "secret" ? (
) : null}
+ ) : (
+
+ {userSecretsEnabled ? (
+ {
+ const key = event.target.value;
+ const definition = userSecretDefinitions?.find((candidate) => candidate.key === key);
+ onPatch({
+ userSecretKey: key,
+ ...(definition && !row.name.trim() ? { name: definition.key.toUpperCase() } : {}),
+ });
+ }}
+ className="min-w-0 bg-transparent px-2 py-1.5 text-sm font-mono outline-none disabled:pointer-events-none"
+ >
+ Select user secret...
+ {row.userSecretKey && !userSecretDefinitions?.some((definition) => definition.key === row.userSecretKey) ? (
+ Unknown ({row.userSecretKey})
+ ) : null}
+ {(userSecretDefinitions ?? []).map((definition) => (
+
+ {definition.name}
+ {definition.status !== "active" ? ` (${definition.status})` : ""}
+
+ ))}
+
+ ) : (
+ onPatch({ userSecretKey: event.target.value })}
+ />
+ )}
+ onPatch({ required: event.target.value === "required" })}
+ className="border-l border-border bg-transparent px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none disabled:pointer-events-none"
+ >
+ Required
+ Optional
+
+
)}
diff --git a/ui/src/components/environment-variables-editor/index.tsx b/ui/src/components/environment-variables-editor/index.tsx
index b29d7f4e82..d34054ddba 100644
--- a/ui/src/components/environment-variables-editor/index.tsx
+++ b/ui/src/components/environment-variables-editor/index.tsx
@@ -9,8 +9,8 @@ import {
type ReactNode,
} from "react";
import { flushSync } from "react-dom";
-import { AlertCircle, KeyRound, Plus, RotateCcw, Save } from "lucide-react";
-import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
+import { AlertCircle, KeyRound, Plus, RotateCcw, Save, UserRound } from "lucide-react";
+import type { CompanySecret, EnvBinding, UserSecretDefinition } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useOptionalToastActions } from "@/context/ToastContext";
@@ -19,6 +19,7 @@ import { parseDotenv } from "./parse-dotenv";
import {
computeDuplicateNames,
computeRowHealth,
+ computeUserSecretRowHealth,
emptyRow,
envKeyFromSecretName,
rowsFromValue,
@@ -50,6 +51,17 @@ function normalizedEnvKey(value: Record
| null | undefined):
},
] as const;
}
+ if (binding?.type === "user_secret_ref") {
+ return [
+ name,
+ {
+ type: "user_secret_ref",
+ key: typeof binding.key === "string" ? binding.key : "",
+ version: typeof binding.version === "number" ? binding.version : "latest",
+ required: binding.required !== false,
+ },
+ ] as const;
+ }
if (binding?.type === "plain") {
return [
name,
@@ -84,6 +96,8 @@ function rowDirtyFields(row: EnvRow, committedRow: EnvRow | undefined): Environm
row.source !== committedRow.source ||
row.textValue !== committedRow.textValue ||
row.secretId !== committedRow.secretId ||
+ row.userSecretKey !== committedRow.userSecretKey ||
+ row.required !== committedRow.required ||
row.version !== committedRow.version,
};
}
@@ -92,6 +106,11 @@ export interface EnvironmentVariablesEditorProps {
value: Record;
onChange: (next: Record | undefined) => void;
secrets: readonly CompanySecret[];
+ /**
+ * Optional company user-secret definitions. When present, the "User secret"
+ * source becomes a picker; otherwise operators can type the definition key.
+ */
+ userSecretDefinitions?: readonly UserSecretDefinition[];
onCreateSecret: (name: string, value: string) => Promise;
/** Optional "Recently used" picker group + quick-bind chips. */
recentlyUsedSecrets?: readonly CompanySecret[];
@@ -115,6 +134,7 @@ export const EnvironmentVariablesEditor = forwardRef ({ ...row }));
const trailing = next[next.length - 1];
let target: EnvRow;
- if (trailing && !trailing.name && !trailing.textValue && !trailing.secretId) {
+ if (trailing && !trailing.name && !trailing.textValue && !trailing.secretId && !trailing.userSecretKey) {
target = trailing;
} else {
target = emptyRow();
@@ -326,8 +348,15 @@ export const EnvironmentVariablesEditor = forwardRef computeDuplicateNames(rows), [rows]);
const attentionCount = useMemo(
- () => rows.reduce((count, row) => (computeRowHealth(row, secrets) ? count + 1 : count), 0),
- [rows, secrets],
+ () =>
+ rows.reduce(
+ (count, row) =>
+ computeRowHealth(row, secrets) || computeUserSecretRowHealth(row, userSecretDefinitions)
+ ? count + 1
+ : count,
+ 0,
+ ),
+ [rows, secrets, userSecretDefinitions],
);
const quickBind = useMemo(() => {
@@ -372,6 +401,7 @@ export const EnvironmentVariablesEditor = forwardRef{hint} : null}
+ {rows.some((row) => row.source === "user_secret" && row.userSecretKey) ? (
+
+
+
+ User secrets resolve from the user responsible for the run. Required bindings fail until that user
+ sets their value under Secrets → My secrets.
+
+
+ ) : null}
);
diff --git a/ui/src/components/environment-variables-editor/model.test.ts b/ui/src/components/environment-variables-editor/model.test.ts
index 2bb016afea..9e393ebdc8 100644
--- a/ui/src/components/environment-variables-editor/model.test.ts
+++ b/ui/src/components/environment-variables-editor/model.test.ts
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
-import type { CompanySecret } from "@paperclipai/shared";
+import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
import {
computeDuplicateNames,
computeRowHealth,
+ computeUserSecretRowHealth,
emptyRow,
envKeyFromSecretName,
planSourceSwitch,
@@ -13,9 +14,35 @@ import {
type EnvRow,
} from "./model";
+function makeUserSecretDefinition(overrides: { key: string; status?: "active" | "disabled" | "archived" }): UserSecretDefinition {
+ return {
+ id: `def-${overrides.key}`,
+ companyId: "co",
+ key: overrides.key,
+ name: overrides.key.toUpperCase(),
+ description: null,
+ status: overrides.status ?? "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ updatedByAgentId: null,
+ updatedByUserId: null,
+ deletedAt: null,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ };
+}
+
function makeSecret(overrides: Partial & Pick): CompanySecret {
return {
companyId: "co",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: overrides.id,
name: overrides.id.toUpperCase(),
provider: "local_encrypted",
@@ -43,18 +70,38 @@ describe("rowsFromValue", () => {
expect(rowsFromValue({})).toEqual([]);
});
- it("maps legacy string, plain, and secret_ref bindings", () => {
+ it("maps legacy string, plain, secret_ref, and user_secret_ref bindings", () => {
const rows = rowsFromValue({
LEGACY: "raw",
PLAIN: { type: "plain", value: "v" },
REF: { type: "secret_ref", secretId: "s1", version: 2 },
REF_LATEST: { type: "secret_ref", secretId: "s2" },
+ USER_REF: { type: "user_secret_ref", key: "github_token", version: "latest", required: false },
});
- expect(rows.map((r) => ({ name: r.name, source: r.source, textValue: r.textValue, secretId: r.secretId, version: r.version }))).toEqual([
- { name: "LEGACY", source: "text", textValue: "raw", secretId: "", version: "latest" },
- { name: "PLAIN", source: "text", textValue: "v", secretId: "", version: "latest" },
- { name: "REF", source: "secret", textValue: "", secretId: "s1", version: 2 },
- { name: "REF_LATEST", source: "secret", textValue: "", secretId: "s2", version: "latest" },
+ expect(
+ rows.map((r) => ({
+ name: r.name,
+ source: r.source,
+ textValue: r.textValue,
+ secretId: r.secretId,
+ userSecretKey: r.userSecretKey,
+ required: r.required,
+ version: r.version,
+ })),
+ ).toEqual([
+ { name: "LEGACY", source: "text", textValue: "raw", secretId: "", userSecretKey: "", required: true, version: "latest" },
+ { name: "PLAIN", source: "text", textValue: "v", secretId: "", userSecretKey: "", required: true, version: "latest" },
+ { name: "REF", source: "secret", textValue: "", secretId: "s1", userSecretKey: "", required: true, version: 2 },
+ { name: "REF_LATEST", source: "secret", textValue: "", secretId: "s2", userSecretKey: "", required: true, version: "latest" },
+ {
+ name: "USER_REF",
+ source: "user_secret",
+ textValue: "",
+ secretId: "",
+ userSecretKey: "github_token",
+ required: false,
+ version: "latest",
+ },
]);
});
});
@@ -77,15 +124,21 @@ describe("valueFromRows (emit semantics)", () => {
expect(valueFromRows([row({ name: "A", source: "secret", secretId: "" })])).toBeUndefined();
});
- it("emits plain and secret_ref bindings", () => {
+ it("drops user-secret rows without a chosen definition key", () => {
+ expect(valueFromRows([row({ name: "A", source: "user_secret", userSecretKey: "" })])).toBeUndefined();
+ });
+
+ it("emits plain, secret_ref, and user_secret_ref bindings", () => {
expect(
valueFromRows([
row({ name: "A", source: "text", textValue: "1" }),
row({ name: "B", source: "secret", secretId: "s1", version: 2 }),
+ row({ name: "C", source: "user_secret", userSecretKey: "github_token", required: false }),
]),
).toEqual({
A: { type: "plain", value: "1" },
B: { type: "secret_ref", secretId: "s1", version: 2 },
+ C: { type: "user_secret_ref", key: "github_token", version: "latest", required: false },
});
});
@@ -157,6 +210,25 @@ describe("computeRowHealth", () => {
});
});
+describe("computeUserSecretRowHealth", () => {
+ const definitions = [
+ makeUserSecretDefinition({ key: "active" }),
+ makeUserSecretDefinition({ key: "disabled", status: "disabled" }),
+ ];
+
+ it("returns null for healthy user-secret refs and non-user-secret rows", () => {
+ expect(computeUserSecretRowHealth({ ...emptyRow(), source: "text", name: "A" }, definitions)).toBeNull();
+ expect(
+ computeUserSecretRowHealth({ ...emptyRow(), source: "user_secret", userSecretKey: "active" }, definitions),
+ ).toBeNull();
+ });
+
+ it("flags missing and disabled user-secret definitions", () => {
+ expect(computeUserSecretRowHealth({ ...emptyRow(), source: "user_secret", userSecretKey: "gone" }, definitions)?.kind).toBe("missing");
+ expect(computeUserSecretRowHealth({ ...emptyRow(), source: "user_secret", userSecretKey: "disabled" }, definitions)?.kind).toBe("disabled");
+ });
+});
+
describe("planSourceSwitch (§6.3)", () => {
it("is a noop when the source is unchanged", () => {
expect(planSourceSwitch({ ...emptyRow(), source: "text" }, "text")).toEqual({ kind: "noop" });
diff --git a/ui/src/components/environment-variables-editor/model.ts b/ui/src/components/environment-variables-editor/model.ts
index 8a6c7eb60e..182168a570 100644
--- a/ui/src/components/environment-variables-editor/model.ts
+++ b/ui/src/components/environment-variables-editor/model.ts
@@ -1,6 +1,6 @@
-import type { CompanySecret, EnvBinding, SecretVersionSelector } from "@paperclipai/shared";
+import type { CompanySecret, EnvBinding, SecretVersionSelector, UserSecretDefinition } from "@paperclipai/shared";
-export type RowSource = "text" | "secret";
+export type RowSource = "text" | "secret" | "user_secret";
/** Local, per-row UI state. Only a subset is emitted upward (see {@link valueFromRows}). */
export interface EnvRow {
@@ -10,6 +10,8 @@ export interface EnvRow {
source: RowSource;
textValue: string;
secretId: string;
+ userSecretKey: string;
+ required: boolean;
version: SecretVersionSelector;
/** Session-local dismissal of the sensitive-value suggestion (§6.6). */
sensitiveDismissed?: boolean;
@@ -22,7 +24,16 @@ export function nextRowId(): string {
}
export function emptyRow(source: RowSource = "text"): EnvRow {
- return { id: nextRowId(), name: "", source, textValue: "", secretId: "", version: "latest" };
+ return {
+ id: nextRowId(),
+ name: "",
+ source,
+ textValue: "",
+ secretId: "",
+ userSecretKey: "",
+ required: true,
+ version: "latest",
+ };
}
function isSecretRef(binding: unknown): binding is { type: "secret_ref"; secretId?: unknown; version?: unknown } {
@@ -43,35 +54,54 @@ function isPlainObj(binding: unknown): binding is { type: "plain"; value?: unkno
);
}
+function isUserSecretRef(
+ binding: unknown,
+): binding is { type: "user_secret_ref"; key?: unknown; version?: unknown; required?: unknown } {
+ return (
+ typeof binding === "object" &&
+ binding !== null &&
+ "type" in binding &&
+ (binding as { type?: unknown }).type === "user_secret_ref"
+ );
+}
+
/** Build editor rows from the controlled value. No implicit trailing ghost row. */
export function rowsFromValue(value: Record | null | undefined): EnvRow[] {
if (!value || typeof value !== "object") return [];
return Object.entries(value).map(([name, binding]) => {
if (typeof binding === "string") {
- return { id: nextRowId(), name, source: "text" as const, textValue: binding, secretId: "", version: "latest" as const };
+ return { ...emptyRow(), name, textValue: binding };
}
if (isSecretRef(binding)) {
const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest";
return {
- id: nextRowId(),
+ ...emptyRow(),
name,
source: "secret" as const,
- textValue: "",
secretId: typeof binding.secretId === "string" ? binding.secretId : "",
version,
};
}
+ if (isUserSecretRef(binding)) {
+ const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest";
+ return {
+ ...emptyRow(),
+ name,
+ source: "user_secret" as const,
+ userSecretKey: typeof binding.key === "string" ? binding.key : "",
+ required: binding.required !== false,
+ version,
+ };
+ }
if (isPlainObj(binding)) {
return {
- id: nextRowId(),
+ ...emptyRow(),
name,
source: "text" as const,
textValue: typeof binding.value === "string" ? binding.value : "",
- secretId: "",
- version: "latest" as const,
};
}
- return { id: nextRowId(), name, source: "text" as const, textValue: "", secretId: "", version: "latest" as const };
+ return { ...emptyRow(), name };
});
}
@@ -88,6 +118,15 @@ export function valueFromRows(rows: EnvRow[]): Record | unde
if (row.source === "secret") {
if (!row.secretId) continue; // incomplete ref — not emitted
record[name] = { type: "secret_ref", secretId: row.secretId, version: row.version };
+ } else if (row.source === "user_secret") {
+ const key = row.userSecretKey.trim();
+ if (!key) continue;
+ record[name] = {
+ type: "user_secret_ref",
+ key,
+ version: row.version,
+ required: row.required,
+ };
} else {
record[name] = { type: "plain", value: row.textValue };
}
@@ -195,6 +234,30 @@ export function computeRowHealth(row: EnvRow, secrets: readonly CompanySecret[])
return null;
}
+/** Per-row user-secret health. Null when healthy or not a bound user-secret ref. */
+export function computeUserSecretRowHealth(
+ row: EnvRow,
+ definitions: readonly UserSecretDefinition[] | undefined,
+): SecretHealth | null {
+ if (row.source !== "user_secret" || !row.userSecretKey || !definitions?.length) return null;
+ const definition = definitions.find((candidate) => candidate.key === row.userSecretKey);
+ if (!definition) {
+ return {
+ level: "error",
+ kind: "missing",
+ message: "This user secret definition no longer exists — runs will fail until you rebind.",
+ };
+ }
+ if (definition.status !== "active") {
+ return {
+ level: "warn",
+ kind: "disabled",
+ message: "Runs will fail until this user secret definition is re-enabled or rebound.",
+ };
+ }
+ return null;
+}
+
/** Suggest a `lower_snake` secret name from an env KEY (plan §6.5). */
export function secretNameFromKey(key: string): string {
return key
diff --git a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx
index 38b14e343a..54c0b6e2f9 100644
--- a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx
+++ b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx
@@ -80,7 +80,7 @@ export function AssigneeChip({
data-testid="handoff-assignee-chip"
data-kind="unassigned"
>
- No assignee —
+ No responsible —
Unassigned
);
@@ -226,7 +226,7 @@ export function ComposerMentionCoach({
);
}
-/** Live banner shown at the top of the assignee picker while a run is in flight,
+/** Live banner shown at the top of the responsible picker while a run is in flight,
* warning that reassigning will interrupt it. (design surface 2) */
export function AssigneeRunningBanner({
copy,
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 0f6b20973d..135e1932e7 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType }
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { issueStatusText } from "@/lib/status-colors";
import { Link } from "@/lib/router";
-import type { Issue, IssueLabel } from "@paperclipai/shared";
+import { deriveOriginatingActor, type Issue, type IssueLabel } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accessApi } from "../../api/access";
import { agentsApi } from "../../api/agents";
@@ -13,7 +13,7 @@ import { issuesApi } from "../../api/issues";
import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { queryKeys } from "../../lib/queryKeys";
-import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, isAgentTaskTarget } from "../../lib/company-members";
+import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, isAgentTaskTarget } from "../../lib/company-members";
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../../lib/issue-assignee-overrides";
import { useProjectOrder } from "../../hooks/useProjectOrder";
import {
@@ -24,7 +24,7 @@ import {
} from "../../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../../lib/recent-projects";
import { orderItemsBySelectedAndRecent } from "../../lib/recent-selections";
-import { formatAssigneeUserLabel } from "../../lib/assignees";
+import { formatAssigneeUserLabel, formatUserLabel } from "../../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../../lib/issue-execution-policy";
import { formatMonitorOffset } from "../../lib/issue-monitor";
import { extractProviderIdWithFallback } from "../../lib/model-utils";
@@ -384,6 +384,10 @@ export function IssueProperties({
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
+ const userProfileMap = useMemo(
+ () => buildCompanyUserProfileMap(companyMembers?.users),
+ [companyMembers?.users],
+ );
const otherUserOptions = useMemo(
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.users, currentUserId, issue.createdByUserId],
@@ -594,7 +598,7 @@ export function IssueProperties({
{assignee
? "This assignee's adapter does not expose editable task overrides."
- : "Select a compatible agent assignee to edit these overrides."}
+ : "Select a compatible assignee agent to edit these overrides."}
formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
+ const actualUserLabel = (userId: string | null | undefined) => formatUserLabel(userId, userLabelMap);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
- const creatorUserLabel = userLabel(issue.createdByUserId);
+ const creatorUserLabel = actualUserLabel(issue.createdByUserId);
+ const originatingActor = deriveOriginatingActor(issue);
+ const originatingUserProfile =
+ originatingActor?.kind === "user" ? userProfileMap.get(originatingActor.id) : null;
+ const originatingViaAgentName =
+ originatingActor?.kind === "user" && originatingActor.viaAgentId
+ ? agentName(originatingActor.viaAgentId) ?? originatingActor.viaAgentId.slice(0, 8)
+ : null;
const selectedAssigneeValue = issue.assigneeAgentId
? `agent:${issue.assigneeAgentId}`
: issue.assigneeUserId
@@ -1309,14 +1321,14 @@ export function IssueProperties({
);
const assigneeTrigger = assignee ? (
-
+
) : assigneeUserLabel ? (
<>
{assigneeUserLabel}
>
) : (
- None
+ Unassigned
);
// Grouped picker options (design surface 2): a board-users section and an
@@ -2233,23 +2245,35 @@ export function IssueProperties({
) : null}
- {(issue.createdByAgentId || issue.createdByUserId) && (
-
- {issue.createdByAgentId ? (
+ {originatingActor ? (
+
+ {originatingActor.kind === "agent" ? (
-
+
) : (
- <>
-
- {creatorUserLabel ?? "User"}
- >
+
+
+ {originatingViaAgentName ? (
+
+ via {originatingViaAgentName}
+
+ ) : null}
+
)}
- )}
+ ) : null}
{issue.startedAt && (
{formatDateTime(issue.startedAt)}
diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx
index 2062f7e317..36fedc6688 100644
--- a/ui/src/components/routine-sections/editable-sections.tsx
+++ b/ui/src/components/routine-sections/editable-sections.tsx
@@ -132,10 +132,10 @@ export function OverviewSection({
value={editDraft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
- placeholder="Assignee"
- noneLabel="No assignee"
- searchPlaceholder="Search assignees..."
- emptyMessage="No assignees found."
+ placeholder="Responsible"
+ noneLabel="No responsible"
+ searchPlaceholder="Search responsible..."
+ emptyMessage="No responsible found."
onChange={(assigneeAgentId) =>
setEditDraft((current) => ({ ...current, assigneeAgentId }))
}
@@ -157,7 +157,7 @@ export function OverviewSection({
{option.label}
)
) : (
- Assignee
+ Responsible
)
}
renderOption={(option) => {
diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx
index c7be61224c..3241a31af7 100644
--- a/ui/src/components/ui/avatar.tsx
+++ b/ui/src/components/ui/avatar.tsx
@@ -6,16 +6,20 @@ import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
+ shape = "circle",
...props
}: React.ComponentProps & {
size?: "default" | "xs" | "sm" | "lg"
+ shape?: "circle" | "square"
}) {
return (
{
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
});
+ it("formats actual user labels without current-user substitution", () => {
+ expect(formatUserLabel("user-1", new Map([["user-1", "Dotta"]]))).toBe("Dotta");
+ expect(formatUserLabel("user-1", new Map([["user-2", "Someone Else"]]))).toBe("user-");
+ expect(formatUserLabel("local-board")).toBe("Board");
+ });
+
it("suggests the last non-me commenter without changing the actual assignee encoding", () => {
expect(
suggestedCommentAssigneeValue(
diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts
index edc4d5456f..2e45490cd9 100644
--- a/ui/src/lib/assignees.ts
+++ b/ui/src/lib/assignees.ts
@@ -78,6 +78,14 @@ export function formatAssigneeUserLabel(
): string | null {
if (!userId) return null;
if (currentUserId && userId === currentUserId) return "You";
+ return formatUserLabel(userId, userLabels);
+}
+
+export function formatUserLabel(
+ userId: string | null | undefined,
+ userLabels?: ReadonlyMap | Record | null,
+): string | null {
+ if (!userId) return null;
if (userLabels) {
const label = userLabels instanceof Map
? userLabels.get(userId)
diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts
index e17c8418f6..8f0468cb69 100644
--- a/ui/src/lib/inbox.test.ts
+++ b/ui/src/lib/inbox.test.ts
@@ -133,6 +133,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
id,
companyId: "company-1",
agentId,
+ responsibleUserId: null,
invocationSource: "assignment",
triggerDetail: null,
status,
@@ -190,6 +191,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
@@ -1192,11 +1194,21 @@ describe("inbox helpers", () => {
});
it("hides the workspace column option unless isolated workspaces are enabled", () => {
- expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "parent", "labels", "updated"]);
+ expect(getAvailableInboxIssueColumns(false)).toEqual([
+ "status",
+ "id",
+ "assignee",
+ "kickedOffBy",
+ "project",
+ "parent",
+ "labels",
+ "updated",
+ ]);
expect(getAvailableInboxIssueColumns(true)).toEqual([
"status",
"id",
"assignee",
+ "kickedOffBy",
"project",
"workspace",
"parent",
diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts
index 48885d8085..e6b76c640e 100644
--- a/ui/src/lib/inbox.ts
+++ b/ui/src/lib/inbox.ts
@@ -40,6 +40,7 @@ export const inboxIssueColumns = [
"status",
"id",
"assignee",
+ "kickedOffBy",
"project",
"workspace",
"parent",
diff --git a/ui/src/lib/interrupt-handoff.test.ts b/ui/src/lib/interrupt-handoff.test.ts
index 45c1ff201d..44802f140d 100644
--- a/ui/src/lib/interrupt-handoff.test.ts
+++ b/ui/src/lib/interrupt-handoff.test.ts
@@ -188,7 +188,7 @@ describe("classifyAssigneeHandoff", () => {
describe("describeReassignInterrupt", () => {
it("names the running agent in the banner and confirm", () => {
const copy = describeReassignInterrupt({ runningAgentName: "ClaudeCoder" });
- expect(copy.banner).toBe("ClaudeCoder is running — changing the assignee will interrupt this run.");
+ expect(copy.banner).toBe("ClaudeCoder is running — changing the responsible will interrupt this run.");
expect(copy.confirmAction).toBe("Interrupt & assign");
expect(copy.cancelAction).toBe("Cancel");
});
diff --git a/ui/src/lib/interrupt-handoff.ts b/ui/src/lib/interrupt-handoff.ts
index 6d71afc6c1..e2f1388b4d 100644
--- a/ui/src/lib/interrupt-handoff.ts
+++ b/ui/src/lib/interrupt-handoff.ts
@@ -248,7 +248,7 @@ export function computeComposerHandoffPreview(
return {
kind: "clear_assignee",
tone: "neutral",
- text: "Clear assignee — no agent will be notified",
+ text: "Clear responsible — no agent will be notified",
};
}
@@ -298,7 +298,7 @@ export function classifyAssigneeHandoff(
opts: { agentName?: string | null; interruptedRunAttached?: boolean } = {},
): AssigneeHandoffInfo {
if (to.agentId) {
- const who = opts.agentName ?? "the assigned agent";
+ const who = opts.agentName ?? "the responsible agent";
const suffix = opts.interruptedRunAttached ? " (interrupted run attached)" : "";
return { kind: "agent_wake", wakeText: `queued for ${who}${suffix}` };
}
@@ -310,7 +310,7 @@ export function classifyAssigneeHandoff(
}
return {
kind: "unassigned",
- wakeText: "not created — no agent selected. Mention @agent or pick an assignee to dispatch.",
+ wakeText: "not created — no agent selected. Mention @agent or pick a responsible to dispatch.",
};
}
@@ -328,7 +328,7 @@ export interface ReassignInterruptCopy {
}
/**
- * Copy for the assignee picker's live-run states: a banner warning that an
+ * Copy for the responsible picker's live-run states: a banner warning that an
* in-flight run will be interrupted, and the confirm step shown when the
* operator picks a *different* target mid-run. Naming the running agent keeps
* the interrupt consequence concrete instead of a bare "are you sure".
@@ -336,7 +336,7 @@ export interface ReassignInterruptCopy {
export function describeReassignInterrupt(opts: { runningAgentName?: string | null } = {}): ReassignInterruptCopy {
const who = opts.runningAgentName?.trim() || "An agent";
return {
- banner: `${who} is running — changing the assignee will interrupt this run.`,
+ banner: `${who} is running — changing the responsible will interrupt this run.`,
confirmTitle: "Interrupt the current run?",
confirmAction: "Interrupt & assign",
cancelAction: "Cancel",
@@ -386,9 +386,9 @@ const PAUSE_BUCKET_LABEL: Record = {
const PAUSE_BUCKET_DETAIL: Record = {
live_runs: "interrupted now, re-queued when you resume",
queued_wakes: "held — they won't start until you resume",
- agent_owned: "assigned to an agent; no run is live",
+ agent_owned: "responsible agent; no run is live",
human_owned: "owned by a board user; pausing won't notify them",
- static: "no assignee; nothing was going to run",
+ static: "no responsible; nothing was going to run",
};
/**
diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts
index 4e210b19bf..f87e7232a0 100644
--- a/ui/src/lib/issue-filters.test.ts
+++ b/ui/src/lib/issue-filters.test.ts
@@ -24,6 +24,7 @@ function makeIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/src/lib/issue-tree.test.ts b/ui/src/lib/issue-tree.test.ts
index 1fb43eeda8..28c94f2be9 100644
--- a/ui/src/lib/issue-tree.test.ts
+++ b/ui/src/lib/issue-tree.test.ts
@@ -18,6 +18,7 @@ function makeIssue(id: string, parentId: string | null = null): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts
index cc92b21c5e..3c64ce33d0 100644
--- a/ui/src/lib/issueDetailBreadcrumb.test.ts
+++ b/ui/src/lib/issueDetailBreadcrumb.test.ts
@@ -46,6 +46,7 @@ describe("issueDetailBreadcrumb", () => {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts
index c843af6a52..58678bfaa0 100644
--- a/ui/src/lib/issueDetailCache.test.ts
+++ b/ui/src/lib/issueDetailCache.test.ts
@@ -31,6 +31,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
diff --git a/ui/src/lib/issueDetailQuery.test.tsx b/ui/src/lib/issueDetailQuery.test.tsx
index 541350ee1f..0f70f5bc6c 100644
--- a/ui/src/lib/issueDetailQuery.test.tsx
+++ b/ui/src/lib/issueDetailQuery.test.tsx
@@ -33,6 +33,7 @@ function makeIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts
index cd912379d0..c901725433 100644
--- a/ui/src/lib/optimistic-issue-comments.test.ts
+++ b/ui/src/lib/optimistic-issue-comments.test.ts
@@ -450,6 +450,7 @@ describe("optimistic issue comments", () => {
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
@@ -520,6 +521,7 @@ describe("optimistic issue comments", () => {
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
@@ -694,6 +696,7 @@ describe("optimistic issue comments", () => {
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
@@ -736,6 +739,7 @@ describe("optimistic issue comments", () => {
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/src/lib/pipeline-liveness.ts b/ui/src/lib/pipeline-liveness.ts
index b3d2aa3691..7038c92768 100644
--- a/ui/src/lib/pipeline-liveness.ts
+++ b/ui/src/lib/pipeline-liveness.ts
@@ -28,7 +28,7 @@ export interface LivenessBannerView {
blockerLink: LivenessBannerLink | null;
/** Secondary link to the linked automation/work task, when one is known. */
automationLink: LivenessBannerLink | null;
- /** Permission key the configured assignee is missing (e.g. `pipelines:write`). */
+ /** Permission key the configured responsible is missing (e.g. `pipelines:write`). */
permissionKey: string | null;
/** Whether a retry call-to-action should render. */
showRetry: boolean;
@@ -145,7 +145,7 @@ export function derivePipelineLivenessBanner(
retryKind: null,
retryLabel: "",
helperNote:
- "Grant the access above to the configured assignee, then Paperclip retries automatically.",
+ "Grant the access above to the configured responsible, then Paperclip retries automatically.",
};
case "automation_failed": {
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index 2fbc9a9224..f78b478999 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -245,6 +245,10 @@ export const queryKeys = {
providerConfigs: (companyId: string) => ["secret-provider-configs", companyId] as const,
usage: (secretId: string) => ["secrets", "usage", secretId] as const,
accessEvents: (secretId: string) => ["secrets", "access-events", secretId] as const,
+ userDefinitions: (companyId: string) => ["user-secret-definitions", companyId] as const,
+ userDefinitionCoverage: (companyId: string, definitionId: string) =>
+ ["user-secret-definitions", companyId, definitionId, "coverage"] as const,
+ myUserSecrets: (companyId: string) => ["my-user-secrets", companyId] as const,
},
companySearch: {
search: (companyId: string, q: string, scope: string, limit: number, offset: number) =>
diff --git a/ui/src/lib/recent-selections.test.ts b/ui/src/lib/recent-selections.test.ts
index 6d376a2c7e..6f50e9980a 100644
--- a/ui/src/lib/recent-selections.test.ts
+++ b/ui/src/lib/recent-selections.test.ts
@@ -36,7 +36,7 @@ describe("recent selection ordering", () => {
it("keeps the no-value option first when it is selected", () => {
const ordered = orderItemsBySelectedAndRecent(
[
- { id: "", label: "No assignee" },
+ { id: "", label: "No responsible" },
{ id: "agent-1", label: "Agent 1" },
{ id: "agent-2", label: "Agent 2" },
],
diff --git a/ui/src/lib/subIssueDefaults.test.ts b/ui/src/lib/subIssueDefaults.test.ts
index 6d45f5d859..46224a09be 100644
--- a/ui/src/lib/subIssueDefaults.test.ts
+++ b/ui/src/lib/subIssueDefaults.test.ts
@@ -48,6 +48,7 @@ function makeIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/src/lib/system-notice-comment.test.ts b/ui/src/lib/system-notice-comment.test.ts
index edf274f67f..afc07489c2 100644
--- a/ui/src/lib/system-notice-comment.test.ts
+++ b/ui/src/lib/system-notice-comment.test.ts
@@ -13,7 +13,7 @@ describe("mapCommentMetadataToSystemNoticeSections", () => {
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
- { type: "agent_link", label: "Assignee", agentId: "agent-1", name: "CodexCoder" },
+ { type: "agent_link", label: "Responsible", agentId: "agent-1", name: "CodexCoder" },
{ type: "key_value", label: "Status before", value: "in_progress" },
{ type: "code", label: "Cause code", code: "missing_disposition" },
{ type: "text", label: "Notes", text: "Pick a disposition." },
@@ -37,7 +37,7 @@ describe("mapCommentMetadataToSystemNoticeSections", () => {
href: "/issues/PAP-3440",
title: "Recovery",
},
- { kind: "agent", label: "Assignee", name: "CodexCoder", href: "/agents/agent-1" },
+ { kind: "agent", label: "Responsible", name: "CodexCoder", href: "/agents/agent-1" },
{ kind: "text", label: "Status before", value: "in_progress" },
{ kind: "code", label: "Cause code", value: "missing_disposition" },
{ kind: "text", label: "Notes", value: "Pick a disposition." },
diff --git a/ui/src/lib/work-mode-meta.ts b/ui/src/lib/work-mode-meta.ts
index abeabc6392..19a8404d39 100644
--- a/ui/src/lib/work-mode-meta.ts
+++ b/ui/src/lib/work-mode-meta.ts
@@ -84,7 +84,7 @@ export function nextWorkMode(mode: IssueWorkMode): IssueWorkMode {
export function titleForPendingWorkMode(mode: IssueWorkMode): string {
if (mode === "ask") {
- return "Ask mode for this submission. Click to change. The assignee will answer in this thread; no implementation work.";
+ return "Ask mode for this submission. Click to change. The responsible will answer in this thread; no implementation work.";
}
if (mode === "planning") {
return "Plan mode is on for this submission. Click to change.";
diff --git a/ui/src/lib/workspace-routines.test.ts b/ui/src/lib/workspace-routines.test.ts
index c585a8014b..40f647d3f9 100644
--- a/ui/src/lib/workspace-routines.test.ts
+++ b/ui/src/lib/workspace-routines.test.ts
@@ -13,6 +13,7 @@ function createRoutine(overrides: Partial = {}): RoutineListIte
projectId: "project-1",
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Routine title",
description: null,
assigneeAgentId: "agent-1",
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index 7ca04287f8..a5a41588aa 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -14,6 +14,7 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity";
+import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { usePanel } from "../context/PanelContext";
@@ -92,7 +93,10 @@ import {
type AgentRuntimeState,
type LiveEvent,
type WorkspaceOperation,
+ isResponsibleUserDenialCode,
+ responsibleUserLabel,
} from "@paperclipai/shared";
+import { ResponsibleUserDenialNotice } from "../components/ResponsibleUserDenialNotice";
import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
@@ -3142,6 +3146,20 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
});
const run = hydratedRun ?? initialRun;
const metrics = runMetrics(run);
+ const { data: userDirectory } = useQuery({
+ queryKey: queryKeys.access.companyUserDirectory(run.companyId),
+ queryFn: () => accessApi.listUserDirectory(run.companyId),
+ enabled: Boolean(run.companyId && run.responsibleUserId),
+ retry: false,
+ });
+ const responsibleUserName = useMemo(() => {
+ if (!run.responsibleUserId) return null;
+ const entry = userDirectory?.users.find(
+ (candidate) => candidate.principalId === run.responsibleUserId,
+ );
+ return entry?.user?.name ?? entry?.user?.email ?? null;
+ }, [run.responsibleUserId, userDirectory]);
+ const responsibleDenialCode = isResponsibleUserDenialCode(run.errorCode) ? run.errorCode : null;
const [sessionOpen, setSessionOpen] = useState(false);
const [claudeLoginResult, setClaudeLoginResult] = useState(null);
@@ -3348,6 +3366,17 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
);
})()}
+ {run.responsibleUserId && (
+
+ On behalf of{" "}
+
+ {responsibleUserName ?? responsibleUserLabel(null)}
+
+
+ )}
{resumeRun.isError && (
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
@@ -3429,6 +3458,12 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
)}
)}
+ {responsibleDenialCode && (
+
+ )}
{hasNonZeroExit && (
Exit code {run.exitCode}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index 4e65b3df47..6b5c449c80 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -267,6 +267,9 @@ const DESIGN_GUIDE_SECRETS: CompanySecret[] = [
{
id: "dg-github",
companyId: "dg",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "github_token",
name: "GITHUB_TOKEN",
provider: "local_encrypted",
@@ -288,6 +291,9 @@ const DESIGN_GUIDE_SECRETS: CompanySecret[] = [
{
id: "dg-db",
companyId: "dg",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "db_connection",
name: "DB_CONNECTION",
provider: "local_encrypted",
@@ -1112,7 +1118,7 @@ export function DesignGuide() {
}
identifier="PAP-001"
title="Implement authentication flow"
- subtitle="Assigned to Agent Alpha"
+ subtitle="Responsible: Agent Alpha"
trailing={
}
onClick={() => {}}
/>
@@ -1418,7 +1424,7 @@ export function DesignGuide() {
-
Assignee
+
Responsible
A
Agent Alpha
diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx
index fcf9a0b485..864bf3cf37 100644
--- a/ui/src/pages/Inbox.test.tsx
+++ b/ui/src/pages/Inbox.test.tsx
@@ -166,6 +166,7 @@ function createIssue(overrides: Partial
= {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 904,
@@ -444,6 +445,7 @@ describe("FailedRunInboxRow", () => {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
+ responsibleUserId: null,
invocationSource: "assignment",
triggerDetail: null,
status: "failed",
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 4ad60fcbb9..c624483395 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -1,7 +1,7 @@
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
+import { deriveOriginatingActor, INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
@@ -2198,7 +2198,7 @@ export function Inbox() {
{([
["none", "None"],
["type", "Type"],
- ["assignee", "Assignee"],
+ ["assignee", "Responsible"],
["project", "Project"],
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
] as const).map(([value, label]) => (
@@ -2382,6 +2382,10 @@ export function Inbox() {
const assigneeUserProfile = issue.assigneeUserId
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
: null;
+ const originatingActor = deriveOriginatingActor(issue);
+ const originatingUserId = originatingActor?.kind === "user" ? originatingActor.id : null;
+ const originatingViaAgentId =
+ originatingActor?.kind === "user" ? originatingActor.viaAgentId ?? null : null;
const isLive = liveIssueIds.has(issue.id);
const loadedSubtreeLiveCount = subtreeLiveCounts.get(issue.id) ?? 0;
const liveDescendantCount = resolveIssueLiveDescendantCount(issue, loadedSubtreeLiveCount);
@@ -2488,6 +2492,10 @@ export function Inbox() {
?? null
}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
+ creatorAgentName={agentName(issue.createdByAgentId)}
+ creatorUserName={originatingUserId ? (companyUserProfileMap.get(originatingUserId)?.label ?? null) : null}
+ creatorUserAvatarUrl={originatingUserId ? (companyUserProfileMap.get(originatingUserId)?.image ?? null) : null}
+ viaAgentName={originatingViaAgentId ? agentName(originatingViaAgentId) : null}
currentUserId={currentUserId}
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index 81072ba88c..f4414694ad 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -7,7 +7,11 @@ import { NavigationType } from "react-router-dom";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { canBoardResolveRecoveryAction, IssueDetail, shouldScrollIssueDetailToTopOnNavigation } from "./IssueDetail";
+import {
+ canBoardResolveRecoveryAction,
+ IssueDetail,
+ shouldScrollIssueDetailToTopOnNavigation,
+} from "./IssueDetail";
import { queryKeys } from "../lib/queryKeys";
const mockIssuesApi = vi.hoisted(() => ({
@@ -78,6 +82,14 @@ const mockIssueChatThreadRender = vi.hoisted(() => vi.fn());
const mockImageGalleryRender = vi.hoisted(() => vi.fn());
const mockIssueWorkspaceCardRender = vi.hoisted(() => vi.fn());
+class ResizeObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub;
+
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
@@ -316,7 +328,7 @@ vi.mock("../components/ApprovalCard", () => ({
}));
vi.mock("../components/Identity", () => ({
- Identity: () => Identity ,
+ Identity: ({ name, shape }: { name: string; shape?: string }) => {name} ,
}));
vi.mock("@/components/ui/button", () => ({
@@ -1001,6 +1013,127 @@ describe("IssueDetail", () => {
).toBe(false);
});
+ it("shows assignee and originating avatars in the issue header metadata", async () => {
+ mockIssuesApi.get.mockResolvedValue(createIssue({
+ assigneeAgentId: "agent-1",
+ projectId: "project-1",
+ createdByUserId: "user-1",
+ }));
+ mockAgentsApi.list.mockResolvedValue([createAgent({ name: "CodexCoder" })]);
+ mockProjectsApi.list.mockResolvedValue([{ id: "project-1", name: "Core Product", color: "#2563eb" }]);
+ mockAccessApi.listUserDirectory.mockResolvedValue({
+ users: [
+ {
+ principalId: "user-1",
+ status: "active",
+ user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: null },
+ },
+ ],
+ });
+ mockAuthApi.getSession.mockResolvedValue({
+ session: { userId: "user-1" },
+ user: { id: "user-1" },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ await waitForAssertion(() => {
+ const avatarStack = container.querySelector('[data-testid="issue-attribution-avatar-stack"]');
+ const assigneeAvatar = container.querySelector('[data-testid="issue-assignee-avatar"]');
+ const originatingAvatar = container.querySelector('[data-testid="issue-originating-avatar"]');
+
+ expect(container.textContent).toContain("Core Product");
+ expect(avatarStack).toBeTruthy();
+ expect(assigneeAvatar?.getAttribute("aria-label")).toBe("Assignee: CodexCoder");
+ expect(originatingAvatar?.getAttribute("aria-label")).toBe("Originating: Dotta");
+ expect(assigneeAvatar?.getAttribute("title")).toBeNull();
+ expect(originatingAvatar?.getAttribute("title")).toBeNull();
+ expect(avatarStack?.textContent).not.toContain("Assignee");
+ expect(avatarStack?.textContent).not.toContain("Originating");
+ expect(avatarStack?.textContent).not.toContain("CodexCoder");
+ expect(avatarStack?.textContent).not.toContain("Dotta");
+ });
+
+ const pointerEvent = window.PointerEvent ?? MouseEvent;
+ const assigneeAvatar = container.querySelector('[data-testid="issue-assignee-avatar"]');
+ const originatingAvatar = container.querySelector('[data-testid="issue-originating-avatar"]');
+
+ await act(async () => {
+ assigneeAvatar?.dispatchEvent(new pointerEvent("pointermove", { bubbles: true }));
+ });
+ await waitForAssertion(() => {
+ const tooltip = document.body.querySelector('[data-testid="issue-assignee-tooltip"]');
+ expect(tooltip?.textContent).toContain("Assignee");
+ expect(tooltip?.textContent).toContain("CodexCoder");
+ });
+
+ await act(async () => {
+ originatingAvatar?.dispatchEvent(new pointerEvent("pointermove", { bubbles: true }));
+ });
+ await waitForAssertion(() => {
+ const tooltip = document.body.querySelector('[data-testid="issue-originating-tooltip"]');
+ expect(tooltip?.textContent).toContain("Originating");
+ expect(tooltip?.textContent).toContain("Dotta");
+ });
+ });
+
+ it("attributes an agent-created issue to the transitive responsible user with a via affordance", async () => {
+ mockIssuesApi.get.mockResolvedValue(createIssue({
+ assigneeAgentId: "agent-1",
+ createdByAgentId: "agent-1",
+ createdByUserId: null,
+ responsibleUserId: "user-1",
+ }));
+ mockAgentsApi.list.mockResolvedValue([createAgent({ name: "CodexCoder" })]);
+ mockAccessApi.listUserDirectory.mockResolvedValue({
+ users: [
+ {
+ principalId: "user-1",
+ status: "active",
+ user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: null },
+ },
+ ],
+ });
+ mockAuthApi.getSession.mockResolvedValue({
+ session: { userId: "user-1" },
+ user: { id: "user-1" },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ await waitForAssertion(() => {
+ const originatingAvatar = container.querySelector('[data-testid="issue-originating-avatar"]');
+ expect(originatingAvatar?.getAttribute("aria-label")).toBe("Originating: Dotta · via CodexCoder");
+ });
+
+ const pointerEvent = window.PointerEvent ?? MouseEvent;
+ const originatingAvatar = container.querySelector('[data-testid="issue-originating-avatar"]');
+ await act(async () => {
+ originatingAvatar?.dispatchEvent(new pointerEvent("pointermove", { bubbles: true }));
+ });
+ await waitForAssertion(() => {
+ const tooltip = document.body.querySelector('[data-testid="issue-originating-tooltip"]');
+ expect(tooltip?.textContent).toContain("Dotta");
+ expect(tooltip?.textContent).toContain("via CodexCoder");
+ });
+ });
+
it("does not mark the wake comment for the current live run as queued when active-run cache is stale", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_progress",
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 9c35821e25..c4f44c6517 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -18,7 +18,7 @@ import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
-import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
+import { assigneeValueFromSelection, formatAssigneeUserLabel, formatUserLabel, suggestedCommentAssigneeValue } from "../lib/assignees";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
@@ -112,6 +112,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
+import { Avatar, AvatarFallback, AvatarGroup, AvatarImage } from "@/components/ui/avatar";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -162,6 +164,7 @@ import {
XCircle,
} from "lucide-react";
import {
+ deriveOriginatingActor,
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
@@ -459,6 +462,130 @@ function ActorIdentity({ evt, agentMap, userProfileMap }: { evt: ActivityEvent;
return ;
}
+export type AttributionActor = {
+ kind: "agent" | "user";
+ id: string;
+ name: string;
+ avatarUrl?: string | null;
+};
+
+function attributionInitials(name: string): string {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+ return name.slice(0, 2).toUpperCase();
+}
+
+function AttributionAvatar({
+ label,
+ actor,
+ via,
+}: {
+ label: "Assignee" | "Originating";
+ actor: AttributionActor;
+ via?: string | null;
+}) {
+ const accessibleLabel = via ? `${label}: ${actor.name} · via ${via}` : `${label}: ${actor.name}`;
+ const testIdLabel = label.toLowerCase();
+
+ return (
+
+
+
+ {actor.avatarUrl ? : null}
+ {attributionInitials(actor.name)}
+
+
+
+
+
+ {actor.avatarUrl ? : null}
+
+ {attributionInitials(actor.name)}
+
+
+
+
{label}
+
{actor.name}
+ {via ? (
+
via {via}
+ ) : null}
+
+
+
+
+ );
+}
+
+function IssueAttributionByline({
+ issue,
+ agentMap,
+ userProfileMap,
+ userLabelMap,
+}: {
+ issue: Issue;
+ agentMap: Map;
+ userProfileMap: ReadonlyMap;
+ userLabelMap: ReadonlyMap;
+}) {
+ const assignee: AttributionActor | null = issue.assigneeAgentId
+ ? {
+ kind: "agent",
+ id: issue.assigneeAgentId,
+ name: agentMap.get(issue.assigneeAgentId)?.name ?? issue.assigneeAgentId.slice(0, 8),
+ }
+ : issue.assigneeUserId
+ ? {
+ kind: "user",
+ id: issue.assigneeUserId,
+ name: formatUserLabel(issue.assigneeUserId, userLabelMap)
+ ?? userProfileMap.get(issue.assigneeUserId)?.label
+ ?? "User",
+ avatarUrl: userProfileMap.get(issue.assigneeUserId)?.image ?? null,
+ }
+ : null;
+ const originatingActor = deriveOriginatingActor(issue);
+ const originator: AttributionActor | null = originatingActor
+ ? originatingActor.kind === "agent"
+ ? {
+ kind: "agent",
+ id: originatingActor.id,
+ name: agentMap.get(originatingActor.id)?.name ?? originatingActor.id.slice(0, 8),
+ }
+ : {
+ kind: "user",
+ id: originatingActor.id,
+ name: formatUserLabel(originatingActor.id, userLabelMap)
+ ?? userProfileMap.get(originatingActor.id)?.label
+ ?? "User",
+ avatarUrl: userProfileMap.get(originatingActor.id)?.image ?? null,
+ }
+ : null;
+ const originatorVia =
+ originatingActor?.kind === "user" && originatingActor.viaAgentId
+ ? agentMap.get(originatingActor.viaAgentId)?.name ?? originatingActor.viaAgentId.slice(0, 8)
+ : null;
+ if (!assignee && !originator) return null;
+
+ return (
+
+
+ {assignee ? : null}
+ {originator ? : null}
+
+
+ );
+}
+
function IssueSectionSkeleton({
titleWidth = "w-28",
rows = 3,
@@ -1264,6 +1391,7 @@ function IssueDetailActivityTab({
agentMap={agentMap}
hasLiveRuns={hasLiveRuns}
activityEvents={activity ?? []}
+ resolveUserLabel={(userId) => userProfileMap.get(userId)?.label ?? null}
renderActivityEvent={(evt) => {
const tone = successfulRunHandoffActivityTone(evt.action);
const isHandoffWarning =
@@ -3652,7 +3780,7 @@ export function IssueDetail() {
{childIssues.length === 0
? "Task execution is held until resume. Human comments can still wake the assignee for triage."
- : "Root and descendant execution is held until resume. Human comments can still wake assignees for triage."}
+ : "Root and descendant execution is held until resume. Human comments can still wake assignee agents for triage."}
@@ -3816,6 +3944,13 @@ export function IssueDetail() {
)}
+
+
{(issue.labels ?? []).length > 0 && (
{(issue.labels ?? []).slice(0, 4).map((label) => (
@@ -4448,8 +4583,8 @@ export function IssueDetail() {
Wake affected agents ({previewAffectedAgentCount})
{previewAffectedAgentCount === 0
- ? "No assigned agents are eligible to wake from this preview."
- : "Wake assigned agents after this operation completes."}
+ ? "No assignee agents are eligible to wake from this preview."
+ : "Wake assignee agents after this operation completes."}
diff --git a/ui/src/pages/Pipelines.tsx b/ui/src/pages/Pipelines.tsx
index 7419d2a9f5..41fb43237a 100644
--- a/ui/src/pages/Pipelines.tsx
+++ b/ui/src/pages/Pipelines.tsx
@@ -3029,7 +3029,7 @@ export function PipelineItemDetailView({ pipelineId, caseId }: { pipelineId: str
{retryPlan.data.routine.assigneeAgent.name}
) : (
-
No assignee
+
No responsible
)}
>
) : (
diff --git a/ui/src/pages/ResponsibleUserDenialUxLab.tsx b/ui/src/pages/ResponsibleUserDenialUxLab.tsx
new file mode 100644
index 0000000000..d30abb9671
--- /dev/null
+++ b/ui/src/pages/ResponsibleUserDenialUxLab.tsx
@@ -0,0 +1,235 @@
+import type { ReactNode } from "react";
+import { ResponsibleUserDenialNotice } from "@/components/ResponsibleUserDenialNotice";
+import { cn } from "@/lib/utils";
+
+/**
+ * UX lab for PAP-12462 (P7): run "on behalf of {user}" surfacing + responsible-user
+ * denial copy. Renders before/after of both surfaces with real design tokens so the
+ * states can be captured for UX review. Route: /ux-lab/responsible-user-denial
+ */
+
+function LabSection({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+ {children}
+
+ );
+}
+
+function BeforeAfter({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
+ {label}
+
+
{children}
+
+ );
+}
+
+/** A faithful copy of a run ledger row header (see IssueRunLedger.tsx). */
+function RunLedgerRow({
+ onBehalfOf,
+ denial,
+}: {
+ onBehalfOf?: string | null;
+ denial?: ReactNode;
+}) {
+ return (
+
+
+ Run
+ a1b2c3d4
+ by CodexCoder
+ {onBehalfOf ? (
+
+ on behalf of {onBehalfOf}
+
+ ) : null}
+
+ {denial ? "Failed" : "Succeeded"}
+
+ 2m ago
+
+
+
+ Elapsed 1m 4s
+
+
+ Last useful action 2m ago
+
+
+ Stop {denial ? "Denied" : "Completed"}
+
+
+ {denial}
+
+ );
+}
+
+/** A faithful copy of the run-detail header identity block (see AgentDetail.tsx RunDetail). */
+function RunDetailHeader({ onBehalfOf, denial }: { onBehalfOf?: string | null; denial?: ReactNode }) {
+ return (
+
+
+ Run a1b2c3d4
+
+ {denial ? "failed" : "succeeded"}
+
+
+
+
+ codex local
+
+ anthropic/claude-opus-4-8
+
+ {onBehalfOf ? (
+
+ On behalf of {onBehalfOf}
+
+ ) : null}
+ {denial}
+
+ );
+}
+
+export function ResponsibleUserDenialUxLab() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Forbidden: action not permitted
+
+ (RESPONSIBLE_USER_UNAUTHORIZED)
+
+
+
+
+
+
+
+
+
+
+
+ Forbidden: agent is not permitted to perform this action
+
+ (deny_missing_membership)
+
+
+
+
+ Responsible-user denial notice intentionally absent for non-responsible-user codes.
+
+
+
+
+
+
+
+
+ Forbidden: responsible user unavailable
+
+ (RESPONSIBLE_USER_UNAVAILABLE)
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+ Copy is sourced from the shared describeResponsibleUserDenial contract.
+
+
+
+ );
+}
diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx
index f3f795a06c..2022b98f95 100644
--- a/ui/src/pages/Routines.test.tsx
+++ b/ui/src/pages/Routines.test.tsx
@@ -253,6 +253,7 @@ function createRoutine(overrides: Partial
): RoutineListItem {
projectId: "project-1",
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Routine title",
description: null,
assigneeAgentId: "agent-1",
@@ -293,6 +294,7 @@ function createIssue(overrides: Partial = {}): Issue {
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
+ responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1000,
diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx
index 35cf032143..edac74328e 100644
--- a/ui/src/pages/Routines.tsx
+++ b/ui/src/pages/Routines.tsx
@@ -688,10 +688,10 @@ export function Routines() {
value={draft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
- placeholder="Assignee"
- noneLabel="No assignee"
- searchPlaceholder="Search assignees..."
- emptyMessage="No assignees found."
+ placeholder="Responsible"
+ noneLabel="No responsible"
+ searchPlaceholder="Search responsible..."
+ emptyMessage="No responsible found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
@@ -714,7 +714,7 @@ export function Routines() {
{option.label}
)
) : (
- Assignee
+ Responsible
)
}
renderOption={(option) => {
diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx
index 35ffc85731..fedc48e4da 100644
--- a/ui/src/pages/Secrets.render.test.tsx
+++ b/ui/src/pages/Secrets.render.test.tsx
@@ -5,10 +5,13 @@ import { flushSync } from "react-dom";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
+ CompanySecret,
CompanySecretProviderConfig,
RemoteSecretImportPreviewResult,
SecretProviderConfigDiscoveryPreviewResult,
SecretProviderDescriptor,
+ UserSecretCoverageSummary,
+ UserSecretDefinition,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProviderVaultsTab, Secrets } from "./Secrets";
@@ -37,6 +40,16 @@ const mockSecretsApi = vi.hoisted(() => ({
remove: vi.fn(),
usage: vi.fn(),
accessEvents: vi.fn(),
+ listUserSecretDefinitions: vi.fn(),
+ createUserSecretDefinition: vi.fn(),
+ updateUserSecretDefinition: vi.fn(),
+ removeUserSecretDefinition: vi.fn(),
+ userSecretDefinitionCoverage: vi.fn(),
+ listMyUserSecrets: vi.fn(),
+ createMyUserSecret: vi.fn(),
+ updateMyUserSecret: vi.fn(),
+ rotateMyUserSecret: vi.fn(),
+ removeMyUserSecret: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
@@ -210,12 +223,78 @@ function makeRemoteImportPreview(
};
}
+function makeCompanySecret(overrides: Partial = {}): CompanySecret {
+ return {
+ id: "secret-openai",
+ companyId: "company-1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
+ key: "openai_api_key",
+ name: "OPENAI_API_KEY",
+ provider: "local_encrypted",
+ status: "active",
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 1,
+ description: null,
+ lastResolvedAt: null,
+ lastRotatedAt: null,
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-1",
+ referenceCount: 2,
+ createdAt: new Date("2026-05-06T00:00:00.000Z"),
+ updatedAt: new Date("2026-05-06T00:00:00.000Z"),
+ ...overrides,
+ };
+}
+
+function makeUserSecretDefinition(overrides: Partial = {}): UserSecretDefinition {
+ return {
+ id: "def-github",
+ companyId: "company-1",
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GitHub token",
+ description: "Used when the responsible user's own repos must be reached.",
+ status: "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: "Create a fine-grained PAT with repo read access.",
+ createdByAgentId: null,
+ createdByUserId: "user-1",
+ updatedByAgentId: null,
+ updatedByUserId: "user-1",
+ deletedAt: null,
+ createdAt: new Date("2026-06-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-06-02T00:00:00.000Z"),
+ ...overrides,
+ };
+}
+
+const userSecretCoverage: UserSecretCoverageSummary = {
+ definitionId: "def-github",
+ configuredCount: 3,
+ missingCount: 2,
+ inactiveCount: 0,
+};
+
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
+function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
+ setter?.call(textarea, value);
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
async function openAwsVaultDialog() {
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
@@ -258,6 +337,9 @@ describe("Secrets page layout", () => {
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
mockSecretsApi.remoteImportPreview.mockResolvedValue(makeRemoteImportPreview());
+ mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
+ mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
});
afterEach(() => {
@@ -435,31 +517,8 @@ describe("Secrets page layout", () => {
});
});
- it("opens reference details from the secrets table count", async () => {
- mockSecretsApi.list.mockResolvedValue([
- {
- id: "secret-openai",
- companyId: "company-1",
- key: "openai_api_key",
- name: "OPENAI_API_KEY",
- provider: "local_encrypted",
- status: "active",
- managedMode: "paperclip_managed",
- externalRef: null,
- providerConfigId: null,
- providerMetadata: null,
- latestVersion: 1,
- description: null,
- lastResolvedAt: null,
- lastRotatedAt: null,
- deletedAt: null,
- createdByAgentId: null,
- createdByUserId: "user-1",
- referenceCount: 2,
- createdAt: new Date("2026-05-06T00:00:00.000Z"),
- updatedAt: new Date("2026-05-06T00:00:00.000Z"),
- },
- ]);
+ it("keeps references reachable from the compact secrets row and detail drawer", async () => {
+ mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
mockSecretsApi.usage.mockResolvedValue({
secretId: "secret-openai",
bindings: [
@@ -504,17 +563,27 @@ describe("Secrets page layout", () => {
await flushReact();
const referencesButton = container.querySelector(
- 'button[aria-label="View references for OPENAI_API_KEY"]',
+ 'button[aria-label="Actions for OPENAI_API_KEY"]',
) as HTMLButtonElement | null;
- expect(referencesButton?.textContent).toBe("2");
+ expect(referencesButton).not.toBeNull();
+ const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
+ (row) => row.textContent?.includes("OPENAI_API_KEY"),
+ ) as HTMLElement | undefined;
await act(async () => {
- referencesButton?.click();
+ companyRow?.click();
+ });
+ await flushReact();
+
+ const viewUsageButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("View in Usage"),
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ viewUsageButton?.click();
});
await flushReact();
expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai");
- expect(document.body.textContent).toContain("Secret references");
expect(document.body.textContent).toContain("CodexCoder");
expect(document.body.textContent).toContain("env.OPENAI_API_KEY");
@@ -523,6 +592,224 @@ describe("Secrets page layout", () => {
});
});
+ it("merges company secrets and each-user definitions into the Secrets list", async () => {
+ mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
+ mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([makeUserSecretDefinition()]);
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ expect(container.textContent).toContain("OPENAI_API_KEY");
+ expect(container.textContent).toContain("Personal GitHub token");
+ expect(container.textContent).toContain("Company");
+ expect(container.textContent).toContain("Each user");
+ expect(container.textContent).toContain("3/5 set");
+ expect(container.textContent).not.toContain("User secret definitions");
+
+ const listContainer = container.querySelector('[data-testid="secrets-list-container"]');
+ const tableView = container.querySelector('[data-testid="secrets-table-view"]');
+ const cardView = container.querySelector('[data-testid="secrets-card-view"]');
+ expect(listContainer?.className).toContain("@container");
+ expect(tableView?.className).toContain("@min-[40rem]:block");
+ expect(tableView?.className).not.toContain("md:block");
+ expect(tableView?.querySelector("[role='row']")?.className).toContain("minmax(12rem,2.4fr)");
+ expect(cardView?.className).toContain("@min-[40rem]:hidden");
+ expect(cardView?.className).not.toContain("md:hidden");
+
+ expect(mockSecretsApi.list).toHaveBeenCalledWith("company-1");
+ expect(mockSecretsApi.listUserSecretDefinitions).toHaveBeenCalledWith("company-1");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("creates an each-user secret from the unified New secret dialog", async () => {
+ const definition = makeUserSecretDefinition({ name: "Personal GitHub token" });
+ mockSecretsApi.createUserSecretDefinition.mockResolvedValueOnce(definition);
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const newSecretButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("New secret"),
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ newSecretButton?.click();
+ });
+ await flushReact();
+
+ const eachUserButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Each user",
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ eachUserButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
+ eachUserButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
+ eachUserButton?.click();
+ });
+ await flushReact();
+
+ const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
+ const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
+ const usageGuidance = document.getElementById("new-secret-usage-guidance") as HTMLTextAreaElement;
+ expect(document.getElementById("new-secret-provider")).toBeNull();
+ expect(document.getElementById("new-secret-vault")).toBeNull();
+ expect(document.getElementById("new-secret-value")).toBeNull();
+
+ await act(async () => {
+ setInputValue(nameInput, "Personal GitHub token");
+ setTextareaValue(usageGuidance, "Create a fine-grained PAT with repo read access.");
+ });
+ await flushReact();
+
+ expect(keyInput.value).toBe("PERSONAL_GITHUB_TOKEN");
+
+ const createButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Create user-provided secret"),
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ createButton?.click();
+ });
+ await flushReact();
+
+ expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith("company-1", {
+ name: "Personal GitHub token",
+ description: null,
+ usageGuidance: "Create a fine-grained PAT with repo read access.",
+ key: "PERSONAL_GITHUB_TOKEN",
+ status: "active",
+ });
+ expect(mockSecretsApi.create).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("opens the New secret dialog when provider queries fail", async () => {
+ mockSecretsApi.providers.mockRejectedValueOnce(new ApiError("Providers unavailable", 403, null));
+ mockSecretsApi.providerConfigs.mockRejectedValueOnce(new ApiError("Provider vaults unavailable", 403, null));
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const newSecretButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("New secret"),
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ newSecretButton?.click();
+ });
+ await flushReact();
+
+ expect(document.body.textContent).toContain("Create secret");
+ expect(document.body.textContent).toContain("Select a provider.");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("opens the each-user detail sheet with coverage and set-my-value actions", async () => {
+ const definition = makeUserSecretDefinition();
+ mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([definition]);
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition, secret: null }]);
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+ await flushReact();
+ await flushReact();
+
+ const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find(
+ (row) => row.textContent?.includes("Personal GitHub token"),
+ ) as HTMLElement | undefined;
+ await act(async () => {
+ definitionRow?.click();
+ });
+ await flushReact();
+
+ expect(document.body.textContent).toContain("Personal GitHub token");
+ expect(document.body.textContent).toContain("Details");
+ expect(document.body.textContent).toContain("Coverage");
+ expect(document.body.textContent).toContain("Usage");
+ expect(document.body.textContent).toContain("Access events");
+
+ const viewCoverageButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("View in Coverage"),
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ viewCoverageButton?.click();
+ });
+ await flushReact();
+
+ expect(document.body.textContent).toContain("3 of 5 set");
+ expect(document.body.textContent).toContain("Secret values are never shown here");
+
+ const setValueButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Set my value"),
+ ) as HTMLButtonElement | undefined;
+ await act(async () => {
+ setValueButton?.click();
+ });
+ await flushReact();
+
+ expect(document.body.textContent).toContain("Set your value");
+ expect(document.body.textContent).toContain("PERSONAL_GH_TOKEN");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
it("keeps the new secret value textarea width-constrained for long tokens", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
diff --git a/ui/src/pages/Secrets.test.ts b/ui/src/pages/Secrets.test.ts
index e650ab7a59..771babbca5 100644
--- a/ui/src/pages/Secrets.test.ts
+++ b/ui/src/pages/Secrets.test.ts
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
-import type { SecretProviderDescriptor } from "@paperclipai/shared";
+import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
import {
+ findCreateProviderReplacement,
getAwsManagedPathPreview,
getCreateProviderBlockReason,
getDefaultProviderConfigId,
@@ -19,6 +20,37 @@ const awsProvider: SecretProviderDescriptor = {
configured: true,
};
+const localProvider: SecretProviderDescriptor = {
+ id: "local_encrypted",
+ label: "Local encrypted (default)",
+ requiresExternalRef: false,
+ supportsManagedValues: true,
+ supportsExternalReferences: false,
+ configured: true,
+};
+
+function providerConfig(
+ overrides: Partial & Pick,
+): CompanySecretProviderConfig {
+ return {
+ companyId: "company-1",
+ displayName: overrides.id,
+ status: "ready",
+ isDefault: false,
+ config: {},
+ healthStatus: null,
+ healthCheckedAt: null,
+ healthMessage: null,
+ healthDetails: null,
+ disabledAt: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ ...overrides,
+ };
+}
+
describe("Secrets page provider helpers", () => {
it("previews the derived AWS managed path from provider health details", () => {
const health: SecretProviderHealthResponse = {
@@ -52,7 +84,9 @@ describe("Secrets page provider helpers", () => {
"managed",
null,
),
- ).toBe("AWS Secrets Manager is not configured in this deployment.");
+ ).toBe(
+ "Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default.",
+ );
});
it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => {
@@ -74,27 +108,72 @@ describe("Secrets page provider helpers", () => {
health,
),
).toBe(
- "AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
+ "Deployment default AWS Secrets Manager is not configured. Select a ready provider vault or configure the deployment default. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
);
});
+ it("allows an unconfigured AWS deployment default when a ready AWS provider vault is selected", () => {
+ expect(
+ getCreateProviderBlockReason(
+ { ...awsProvider, configured: false },
+ "external",
+ null,
+ providerConfig({
+ id: "aws-prod",
+ provider: "aws_secrets_manager",
+ displayName: "AWS prod",
+ status: "ready",
+ }),
+ ),
+ ).toBeNull();
+ });
+
+ it("names the selected provider vault block before deployment-default AWS config", () => {
+ expect(
+ getCreateProviderBlockReason(
+ { ...awsProvider, configured: false },
+ "external",
+ null,
+ providerConfig({
+ id: "aws-disabled",
+ provider: "aws_secrets_manager",
+ displayName: "AWS disabled",
+ status: "disabled",
+ }),
+ ),
+ ).toBe("This provider vault is disabled.");
+ });
+
it("blocks provider modes the backend does not support", () => {
expect(
getCreateProviderBlockReason(
- {
- id: "local_encrypted",
- label: "Local encrypted (default)",
- requiresExternalRef: false,
- supportsManagedValues: true,
- supportsExternalReferences: false,
- configured: true,
- },
+ localProvider,
"external",
null,
),
).toBe("Local encrypted (default) does not support linked external references.");
});
+ it("switching to external mode prefers AWS instead of staying on local encrypted when a ready AWS vault exists", () => {
+ expect(
+ findCreateProviderReplacement({
+ providers: [localProvider, { ...awsProvider, configured: false }],
+ providerConfigs: [
+ providerConfig({
+ id: "aws-prod",
+ provider: "aws_secrets_manager",
+ displayName: "AWS prod",
+ status: "ready",
+ isDefault: true,
+ }),
+ ],
+ currentProvider: "local_encrypted",
+ mode: "external",
+ health: null,
+ })?.id,
+ ).toBe("aws_secrets_manager");
+ });
+
it("chooses the ready default provider vault for a provider", () => {
expect(
getDefaultProviderConfigId(
diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx
index 7623ca79f1..0a9cf3ce47 100644
--- a/ui/src/pages/Secrets.tsx
+++ b/ui/src/pages/Secrets.tsx
@@ -8,12 +8,15 @@ import {
Ban,
CheckCircle2,
Cloud,
+ Copy,
Database,
Edit3,
ExternalLink,
KeyRound,
Link2,
+ Lock,
Loader2,
+ MoreHorizontal,
Plus,
RefreshCw,
Search,
@@ -23,6 +26,9 @@ import {
X,
Filter,
Info,
+ Pencil,
+ UserRound,
+ Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import type {
@@ -37,6 +43,8 @@ import type {
SecretProviderConfigStatus,
SecretProviderDescriptor,
SecretStatus,
+ UserSecretCoverageSummary,
+ UserSecretDefinition,
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -49,6 +57,7 @@ import {
type UpdateSecretProviderConfigInput,
} from "../api/secrets";
import { ApiError } from "../api/client";
+import { accessApi, type CompanyUserDirectoryEntry } from "../api/access";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button";
@@ -73,12 +82,34 @@ import {
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "../lib/utils";
+import { copyTextToClipboard } from "../lib/clipboard";
import { PageTabBar } from "../components/PageTabBar";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
+import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab";
+import { SetMyUserSecretDialog } from "./secrets/SetMyUserSecretDialog";
+import {
+ coverageSummaryLabel,
+ UserSecretChip,
+} from "./secrets/user-secret-presentation";
+import type { MyUserSecretEntry } from "../api/secrets";
type CreateMode = "managed" | "external";
-type SecretsTab = "secrets" | "vaults";
+type SecretValueProvider = "company" | "user";
+type ProvidedByFilter = "all" | SecretValueProvider;
+type SecretsTab = "secrets" | "my-secrets" | "vaults";
+
+type UnifiedSecretRow =
+ | { id: string; kind: "company"; secret: CompanySecret }
+ | { id: string; kind: "user"; definition: UserSecretDefinition };
type ProviderVaultForm = {
provider: SecretProvider;
@@ -112,6 +143,12 @@ type SafeProviderErrorDetails = {
safeAlternative?: string;
};
+const EMPTY_SECRETS: CompanySecret[] = [];
+const EMPTY_USER_SECRET_DEFINITIONS: UserSecretDefinition[] = [];
+const EMPTY_MY_USER_SECRETS: MyUserSecretEntry[] = [];
+const EMPTY_SECRET_PROVIDERS: SecretProviderDescriptor[] = [];
+const EMPTY_PROVIDER_CONFIGS: CompanySecretProviderConfig[] = [];
+
const PROVIDER_ORDER: SecretProvider[] = [
"local_encrypted",
"aws_secrets_manager",
@@ -245,6 +282,15 @@ function normalizeSecretKeyForPreview(input: string) {
.slice(0, 120);
}
+function normalizeUserSecretKeyForPreview(input: string) {
+ return input
+ .trim()
+ .toUpperCase()
+ .replace(/[^A-Z0-9_]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .slice(0, 120);
+}
+
function modeLabel(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external";
@@ -256,6 +302,107 @@ function modeDescription(managedMode: SecretManagedMode) {
: "Paperclip resolves this provider reference but does not rotate the provider value.";
}
+function statusLabel(status: SecretStatus) {
+ return status.charAt(0).toUpperCase() + status.slice(1);
+}
+
+function statusDotTone(status: SecretStatus) {
+ switch (status) {
+ case "active":
+ return "bg-emerald-500";
+ case "disabled":
+ return "bg-amber-500";
+ case "archived":
+ return "bg-muted-foreground";
+ case "deleted":
+ return "bg-destructive";
+ default:
+ return "bg-muted-foreground";
+ }
+}
+
+function StatusBadge({ status }: { status: SecretStatus }) {
+ return (
+
+
+ {statusLabel(status)}
+
+ );
+}
+
+function MetaChip({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function providerIndicatorLabel(
+ secret: CompanySecret,
+ providers: SecretProviderDescriptor[],
+ providerConfigs: CompanySecretProviderConfig[],
+) {
+ const provider = providerLabel(providers, secret.provider);
+ const vault = providerVaultLabel(providerConfigs, secret.providerConfigId);
+ const custody = modeLabel(secret.managedMode);
+ return [
+ `${custody} · ${provider}`,
+ vault ? `Vault: ${vault}` : null,
+ secret.externalRef ? `Reference: ${secret.externalRef}` : null,
+ ]
+ .filter(Boolean)
+ .join("\n");
+}
+
+function SecretProviderIndicator({
+ secret,
+ providers,
+ providerConfigs,
+}: {
+ secret: CompanySecret;
+ providers: SecretProviderDescriptor[];
+ providerConfigs: CompanySecretProviderConfig[];
+}) {
+ const label = providerIndicatorLabel(secret, providers, providerConfigs);
+ const Icon = secret.managedMode === "external_reference" ? ExternalLink : Lock;
+ return (
+
+
+
+
+
+
+ {label}
+
+ );
+}
+
+function UpdatedWithTooltip({
+ updatedAt,
+ tooltip,
+}: {
+ updatedAt: Date | string | null | undefined;
+ tooltip: string;
+}) {
+ return (
+
+
+
+ {formatRelative(updatedAt)}
+
+
+ {tooltip}
+
+ );
+}
+
function healthEntryForProvider(
health: SecretProviderHealthResponse | null,
providerId: SecretProvider,
@@ -267,6 +414,7 @@ export function getCreateProviderBlockReason(
provider: SecretProviderDescriptor | null | undefined,
mode: CreateMode,
health: SecretProviderHealthResponse | null,
+ providerConfig?: CompanySecretProviderConfig | null,
) {
if (!provider) return "Select a provider.";
if (mode === "managed" && provider.supportsManagedValues === false) {
@@ -275,11 +423,20 @@ export function getCreateProviderBlockReason(
if (mode === "external" && provider.supportsExternalReferences === false) {
return `${provider.label} does not support linked external references.`;
}
+ const selectedProviderConfigBlockReason = providerConfig?.provider === provider.id
+ ? getProviderConfigBlockReason(providerConfig)
+ : null;
+ const selectedProviderConfigReady =
+ providerConfig?.provider === provider.id && !selectedProviderConfigBlockReason;
if (provider.configured === false) {
+ if (selectedProviderConfigReady) return null;
+ if (selectedProviderConfigBlockReason) return selectedProviderConfigBlockReason;
const healthEntry = healthEntryForProvider(health, provider.id);
+ const deploymentMessage = `Deployment default ${provider.label} is not configured.`;
+ const nextStep = " Select a ready provider vault or configure the deployment default.";
return healthEntry?.message
- ? `${provider.label} is not configured in this deployment. ${healthEntry.message}`
- : `${provider.label} is not configured in this deployment.`;
+ ? `${deploymentMessage}${nextStep} ${healthEntry.message}`
+ : `${deploymentMessage}${nextStep}`;
}
const healthEntry = healthEntryForProvider(health, provider.id);
if (healthEntry?.status === "error") {
@@ -291,8 +448,16 @@ export function getCreateProviderBlockReason(
function providerHealthText(
provider: SecretProviderDescriptor | null | undefined,
health: SecretProviderHealthResponse | null,
+ providerConfig?: CompanySecretProviderConfig | null,
) {
if (!provider) return null;
+ if (
+ provider.configured === false &&
+ providerConfig?.provider === provider.id &&
+ !getProviderConfigBlockReason(providerConfig)
+ ) {
+ return `Using selected provider vault. Deployment default ${provider.label} is not configured.`;
+ }
const entry = healthEntryForProvider(health, provider.id);
if (!entry) return null;
const warnings = entry.warnings?.join(" ");
@@ -316,20 +481,57 @@ export function getProviderConfigBlockReason(
return null;
}
-export function getDefaultProviderConfigId(
+export function getSelectableProviderConfig(
configs: CompanySecretProviderConfig[],
provider: SecretProvider,
) {
const providerConfigs = configs.filter((config) => config.provider === provider);
- const selectable = providerConfigs.filter((config) => !getProviderConfigBlockReason(config));
return (
- selectable.find((config) => config.isDefault)?.id ??
- selectable[0]?.id ??
+ providerConfigs.find((config) => config.isDefault && !getProviderConfigBlockReason(config)) ??
+ providerConfigs.find((config) => !getProviderConfigBlockReason(config)) ??
+ null
+ );
+}
+
+export function getDefaultProviderConfigId(
+ configs: CompanySecretProviderConfig[],
+ provider: SecretProvider,
+) {
+ const selected = getSelectableProviderConfig(configs, provider);
+ const providerConfigs = configs.filter((config) => config.provider === provider);
+ return (
+ selected?.id ??
providerConfigs.find((config) => config.isDefault)?.id ??
""
);
}
+export function findCreateProviderReplacement({
+ providers,
+ providerConfigs,
+ currentProvider,
+ mode,
+ health,
+}: {
+ providers: SecretProviderDescriptor[];
+ providerConfigs: CompanySecretProviderConfig[];
+ currentProvider: SecretProvider;
+ mode: CreateMode;
+ health: SecretProviderHealthResponse | null;
+}) {
+ return (
+ providers.find((provider) => {
+ const selectedConfig =
+ provider.id === currentProvider
+ ? providerConfigs.find(
+ (config) => config.provider === provider.id && !getProviderConfigBlockReason(config),
+ ) ?? null
+ : getSelectableProviderConfig(providerConfigs, provider.id);
+ return !getCreateProviderBlockReason(provider, mode, health, selectedConfig);
+ }) ?? null
+ );
+}
+
function providerVaultLabel(configs: CompanySecretProviderConfig[], id: string | null | undefined) {
if (!id) return "Deployment default";
return configs.find((config) => config.id === id)?.displayName ?? "Unknown vault";
@@ -402,17 +604,23 @@ export function Secrets() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("active");
const [providerFilter, setProviderFilter] = useState("all");
+ const [providedByFilter, setProvidedByFilter] = useState("all");
const [selectedSecretId, setSelectedSecretId] = useState(null);
+ const [selectedDefinitionId, setSelectedDefinitionId] = useState(null);
const [usageDialogSecretId, setUsageDialogSecretId] = useState(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importInitialVaultId, setImportInitialVaultId] = useState(null);
+ const [secretValueProvider, setSecretValueProvider] = useState("company");
const [createMode, setCreateMode] = useState("managed");
+ const [editingDefinition, setEditingDefinition] = useState(null);
+ const [createKeyDirty, setCreateKeyDirty] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
key: "",
value: "",
description: "",
+ usageGuidance: "",
externalRef: "",
provider: "local_encrypted" as SecretProvider,
providerConfigId: "",
@@ -424,6 +632,8 @@ export function Secrets() {
const [rotateProviderConfigId, setRotateProviderConfigId] = useState("");
const [rotateError, setRotateError] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null);
+ const [definitionDeleteConfirm, setDefinitionDeleteConfirm] = useState(null);
+ const [setMyValueFor, setSetMyValueFor] = useState(null);
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
const [editingVault, setEditingVault] = useState(null);
const [removeVaultConfirm, setRemoveVaultConfirm] = useState(null);
@@ -445,6 +655,22 @@ export function Secrets() {
enabled: Boolean(selectedCompanyId),
});
+ const userDefinitionsQuery = useQuery({
+ queryKey: selectedCompanyId
+ ? queryKeys.secrets.userDefinitions(selectedCompanyId)
+ : ["user-secret-definitions", "__disabled__"],
+ queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
+ enabled: Boolean(selectedCompanyId),
+ });
+
+ const myUserSecretsQuery = useQuery({
+ queryKey: selectedCompanyId
+ ? queryKeys.secrets.myUserSecrets(selectedCompanyId)
+ : ["my-user-secrets", "__disabled__"],
+ queryFn: () => secretsApi.listMyUserSecrets(selectedCompanyId!),
+ enabled: Boolean(selectedCompanyId),
+ });
+
const providersQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.providers(selectedCompanyId)
@@ -473,13 +699,26 @@ export function Secrets() {
retry: false,
});
- const secrets = secretsQuery.data ?? [];
- const providers = providersQuery.data ?? [];
- const providerConfigs = providerConfigsQuery.data ?? [];
+ const secrets = secretsQuery.data ?? EMPTY_SECRETS;
+ const userDefinitions = userDefinitionsQuery.data ?? EMPTY_USER_SECRET_DEFINITIONS;
+ const myUserSecrets = myUserSecretsQuery.data ?? EMPTY_MY_USER_SECRETS;
+ const providers = providersQuery.data ?? EMPTY_SECRET_PROVIDERS;
+ const providerConfigs = providerConfigsQuery.data ?? EMPTY_PROVIDER_CONFIGS;
const selectedSecret = useMemo(
() => secrets.find((secret) => secret.id === selectedSecretId) ?? null,
[secrets, selectedSecretId],
);
+ const selectedDefinition = useMemo(
+ () => userDefinitions.find((definition) => definition.id === selectedDefinitionId) ?? null,
+ [selectedDefinitionId, userDefinitions],
+ );
+ const selectedDefinitionMyEntry = useMemo(() => {
+ if (!selectedDefinition) return null;
+ return myUserSecrets.find((entry) => entry.definition.id === selectedDefinition.id) ?? {
+ definition: selectedDefinition,
+ secret: null,
+ };
+ }, [myUserSecrets, selectedDefinition]);
const usageDialogSecret = useMemo(
() => secrets.find((secret) => secret.id === usageDialogSecretId) ?? null,
[secrets, usageDialogSecretId],
@@ -508,11 +747,13 @@ export function Secrets() {
selectedCreateProvider,
createMode,
providerHealthQuery.data ?? null,
+ selectedCreateProviderConfig,
) ?? getProviderConfigBlockReason(selectedCreateProviderConfig);
const rotateProviderBlockReason = getProviderConfigBlockReason(selectedRotateProviderConfig);
const createProviderHealthText = providerHealthText(
selectedCreateProvider,
providerHealthQuery.data ?? null,
+ selectedCreateProviderConfig,
);
const awsManagedPathPreview = getAwsManagedPathPreview({
provider: selectedCreateProvider,
@@ -521,21 +762,49 @@ export function Secrets() {
secretKeySource: createForm.key.trim() || createForm.name,
});
- const filtered = useMemo(() => {
+ const unifiedRows = useMemo(
+ () => [
+ ...secrets.map((secret) => ({ id: `company:${secret.id}`, kind: "company" as const, secret })),
+ ...userDefinitions.map((definition) => ({
+ id: `user:${definition.id}`,
+ kind: "user" as const,
+ definition,
+ })),
+ ],
+ [secrets, userDefinitions],
+ );
+
+ const filteredRows = useMemo(() => {
const needle = search.trim().toLowerCase();
- return secrets.filter((secret) => {
- if (statusFilter !== "all" && secret.status !== statusFilter) return false;
- if (providerFilter !== "all" && secret.provider !== providerFilter) return false;
+ return unifiedRows.filter((row) => {
+ const providedBy: SecretValueProvider = row.kind === "company" ? "company" : "user";
+ const status = row.kind === "company" ? row.secret.status : row.definition.status;
+ if (providedByFilter !== "all" && providedBy !== providedByFilter) return false;
+ if (statusFilter !== "all" && status !== statusFilter) return false;
+ if (providerFilter !== "all" && row.kind === "company" && row.secret.provider !== providerFilter) {
+ return false;
+ }
if (!needle) return true;
+ if (row.kind === "company") {
+ return (
+ row.secret.name.toLowerCase().includes(needle) ||
+ row.secret.key.toLowerCase().includes(needle) ||
+ (row.secret.description?.toLowerCase().includes(needle) ?? false) ||
+ (row.secret.externalRef?.toLowerCase().includes(needle) ?? false)
+ );
+ }
return (
- secret.name.toLowerCase().includes(needle) ||
- secret.key.toLowerCase().includes(needle) ||
- (secret.description?.toLowerCase().includes(needle) ?? false) ||
- (secret.externalRef?.toLowerCase().includes(needle) ?? false)
+ row.definition.name.toLowerCase().includes(needle) ||
+ row.definition.key.toLowerCase().includes(needle) ||
+ (row.definition.description?.toLowerCase().includes(needle) ?? false) ||
+ (row.definition.usageGuidance?.toLowerCase().includes(needle) ?? false)
);
});
- }, [secrets, search, statusFilter, providerFilter]);
- const activeSecretFilterCount = (statusFilter === "active" ? 0 : 1) + (providerFilter === "all" ? 0 : 1);
+ }, [providedByFilter, providerFilter, search, statusFilter, unifiedRows]);
+ const activeSecretFilterCount =
+ (statusFilter === "active" ? 0 : 1) +
+ (providerFilter === "all" ? 0 : 1) +
+ (providedByFilter === "all" ? 0 : 1);
const usageQuery = useQuery({
queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"],
@@ -561,15 +830,78 @@ export function Secrets() {
function invalidateAll(extraIds: string[] = []) {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.providerConfigs(selectedCompanyId) });
for (const id of extraIds) {
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.accessEvents(id) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitionCoverage(selectedCompanyId, id) });
}
}
+ function openCreateSecret() {
+ setEditingDefinition(null);
+ setSecretValueProvider("company");
+ setCreateMode("managed");
+ setCreateKeyDirty(false);
+ setCreateError(null);
+ setCreateForm({
+ name: "",
+ key: "",
+ value: "",
+ description: "",
+ usageGuidance: "",
+ externalRef: "",
+ provider: "local_encrypted",
+ providerConfigId: getDefaultProviderConfigId(providerConfigs, "local_encrypted"),
+ });
+ setCreateOpen(true);
+ }
+
+ function openEditDefinition(definition: UserSecretDefinition) {
+ setEditingDefinition(definition);
+ setSecretValueProvider("user");
+ setCreateMode("managed");
+ setCreateKeyDirty(true);
+ setCreateError(null);
+ setCreateForm({
+ name: definition.name,
+ key: definition.key,
+ value: "",
+ description: definition.description ?? "",
+ usageGuidance: definition.usageGuidance ?? "",
+ externalRef: "",
+ provider: "local_encrypted",
+ providerConfigId: "",
+ });
+ setCreateOpen(true);
+ }
+
const createMutation = useMutation({
- mutationFn: () => {
+ mutationFn: async () => {
+ const sharedDefinitionPayload = {
+ name: createForm.name.trim(),
+ description: createForm.description.trim() || null,
+ usageGuidance: createForm.usageGuidance.trim() || null,
+ };
+ if (editingDefinition) {
+ const definition = await secretsApi.updateUserSecretDefinition(
+ selectedCompanyId!,
+ editingDefinition.id,
+ sharedDefinitionPayload,
+ );
+ return { kind: "user" as const, item: definition, action: "updated" as const };
+ }
+ if (secretValueProvider === "user") {
+ const definition = await secretsApi.createUserSecretDefinition(selectedCompanyId!, {
+ ...sharedDefinitionPayload,
+ key: createForm.key.trim(),
+ status: "active",
+ });
+ return { kind: "user" as const, item: definition, action: "created" as const };
+ }
+
const input: CreateSecretInput = {
name: createForm.name.trim(),
provider: createForm.provider,
@@ -583,23 +915,44 @@ export function Secrets() {
} else {
input.externalRef = createForm.externalRef.trim();
}
- return secretsApi.create(selectedCompanyId!, input);
+ const secret = await secretsApi.create(selectedCompanyId!, input);
+ return { kind: "company" as const, item: secret, action: "created" as const };
},
- onSuccess: (created) => {
- pushToast({ title: "Secret created", body: created.name, tone: "success" });
+ onSuccess: (result) => {
+ pushToast({
+ title:
+ result.kind === "company"
+ ? "Secret created"
+ : result.action === "updated"
+ ? "User-provided secret updated"
+ : "User-provided secret created",
+ body: result.item.name,
+ tone: "success",
+ });
setCreateOpen(false);
+ setEditingDefinition(null);
+ setSecretValueProvider("company");
+ setCreateKeyDirty(false);
setCreateForm({
name: "",
key: "",
value: "",
description: "",
+ usageGuidance: "",
externalRef: "",
provider: createForm.provider,
providerConfigId: getDefaultProviderConfigId(providerConfigs, createForm.provider),
});
setCreateError(null);
- setSelectedSecretId(created.id);
- invalidateAll([created.id]);
+ if (result.kind === "company") {
+ setSelectedSecretId(result.item.id);
+ setSelectedDefinitionId(null);
+ invalidateAll([result.item.id]);
+ } else {
+ setSelectedDefinitionId(result.item.id);
+ setSelectedSecretId(null);
+ invalidateAll([result.item.id]);
+ }
},
onError: (error) => {
setCreateError(error instanceof ApiError ? error.message : (error as Error).message);
@@ -660,6 +1013,22 @@ export function Secrets() {
},
});
+ const definitionStatusMutation = useMutation({
+ mutationFn: ({ definition, status }: { definition: UserSecretDefinition; status: SecretStatus }) =>
+ secretsApi.updateUserSecretDefinition(selectedCompanyId!, definition.id, { status }),
+ onSuccess: (updated) => {
+ pushToast({ title: `User-provided secret ${updated.status}`, body: updated.name, tone: "info" });
+ invalidateAll([updated.id]);
+ },
+ onError: (error) => {
+ pushToast({
+ title: "Status update failed",
+ body: error instanceof Error ? error.message : "Try again",
+ tone: "error",
+ });
+ },
+ });
+
const deleteMutation = useMutation({
mutationFn: (id: string) => secretsApi.remove(id),
onSuccess: (_response, id) => {
@@ -677,6 +1046,24 @@ export function Secrets() {
},
});
+ const deleteDefinitionMutation = useMutation({
+ mutationFn: (definition: UserSecretDefinition) =>
+ secretsApi.removeUserSecretDefinition(selectedCompanyId!, definition.id),
+ onSuccess: (_response, definition) => {
+ pushToast({ title: "User-provided secret removed", body: definition.name, tone: "info" });
+ setDefinitionDeleteConfirm(null);
+ if (selectedDefinitionId === definition.id) setSelectedDefinitionId(null);
+ invalidateAll([definition.id]);
+ },
+ onError: (error) => {
+ pushToast({
+ title: "Delete failed",
+ body: error instanceof Error ? error.message : "Try again",
+ tone: "error",
+ });
+ },
+ });
+
const saveVaultMutation = useMutation({
mutationFn: () => {
const data: CreateSecretProviderConfigInput | UpdateSecretProviderConfigInput = {
@@ -795,12 +1182,16 @@ export function Secrets() {
providers.find((provider) => provider.id === createForm.provider) ?? null,
createMode,
providerHealthQuery.data ?? null,
+ providerConfigs.find((config) => config.id === createForm.providerConfigId) ?? null,
);
if (!currentBlockReason) return;
- const replacement = providers.find(
- (provider) =>
- !getCreateProviderBlockReason(provider, createMode, providerHealthQuery.data ?? null),
- );
+ const replacement = findCreateProviderReplacement({
+ providers,
+ providerConfigs,
+ currentProvider: createForm.provider,
+ mode: createMode,
+ health: providerHealthQuery.data ?? null,
+ });
if (replacement && replacement.id !== createForm.provider) {
setCreateForm((current) => ({
...current,
@@ -814,9 +1205,11 @@ export function Secrets() {
if (!createOpen) return;
const current = providerConfigs.find((config) => config.id === createForm.providerConfigId);
if (current?.provider === createForm.provider) return;
+ const nextProviderConfigId = getDefaultProviderConfigId(providerConfigs, createForm.provider);
+ if (nextProviderConfigId === createForm.providerConfigId) return;
setCreateForm((form) => ({
...form,
- providerConfigId: getDefaultProviderConfigId(providerConfigs, form.provider),
+ providerConfigId: nextProviderConfigId,
}));
}, [createForm.provider, createForm.providerConfigId, createOpen, providerConfigs]);
@@ -865,6 +1258,172 @@ export function Secrets() {
}));
}
+ function openCompanySecret(secret: CompanySecret) {
+ setSecretDetailTab("details");
+ setSelectedSecretId(secret.id);
+ setSelectedDefinitionId(null);
+ }
+
+ function openUserDefinition(definition: UserSecretDefinition) {
+ setSecretDetailTab("details");
+ setSelectedDefinitionId(definition.id);
+ setSelectedSecretId(null);
+ }
+
+ function openRotateSecret(secret: CompanySecret) {
+ openCompanySecret(secret);
+ setRotateOpen(true);
+ setRotateValue("");
+ setRotateExternalRef("");
+ setRotateProviderConfigId(
+ secret.providerConfigId ?? getDefaultProviderConfigId(providerConfigs, secret.provider),
+ );
+ setRotateError(null);
+ }
+
+ function copySecretKey(key: string) {
+ void copyTextToClipboard(key)
+ .then(() => pushToast({ title: "Secret key copied", body: key, tone: "success" }))
+ .catch((error) =>
+ pushToast({
+ title: "Copy failed",
+ body: error instanceof Error ? error.message : "Unable to copy secret key",
+ tone: "error",
+ }),
+ );
+ }
+
+ function renderRowActions(row: UnifiedSecretRow) {
+ const name = row.kind === "company" ? row.secret.name : row.definition.name;
+ return (
+
+
+ event.stopPropagation()}
+ >
+
+
+
+
+ {
+ if (row.kind === "company") openCompanySecret(row.secret);
+ else openUserDefinition(row.definition);
+ }}
+ >
+ View details
+
+ {row.kind === "company" ? (
+ <>
+ setUsageDialogSecretId(row.secret.id)}>
+ View references ({row.secret.referenceCount ?? 0})
+
+ openRotateSecret(row.secret)}>
+
+ {row.secret.managedMode === "external_reference" ? "Update reference" : "Update value"}
+
+
+
+ statusMutation.mutate({
+ id: row.secret.id,
+ status: row.secret.status === "active" ? "disabled" : "active",
+ })
+ }
+ >
+ {row.secret.status === "active" ? : }
+ {row.secret.status === "active" ? "Disable" : "Activate"}
+
+
+ statusMutation.mutate({
+ id: row.secret.id,
+ status: row.secret.status === "archived" ? "active" : "archived",
+ })
+ }
+ >
+ {row.secret.status === "archived" ? (
+
+ ) : (
+
+ )}
+ {row.secret.status === "archived" ? "Unarchive" : "Archive"}
+
+
+ setDeleteConfirm(row.secret)}>
+ Delete secret
+
+ >
+ ) : (
+ <>
+
+ setSetMyValueFor(
+ myUserSecrets.find((entry) => entry.definition.id === row.definition.id) ?? {
+ definition: row.definition,
+ secret: null,
+ },
+ )
+ }
+ >
+
+ {myUserSecrets.find((entry) => entry.definition.id === row.definition.id)?.secret
+ ? "Update my value"
+ : "Set my value"}
+
+ openEditDefinition(row.definition)}>
+ Edit definition
+
+
+
+ definitionStatusMutation.mutate({
+ definition: row.definition,
+ status: row.definition.status === "active" ? "disabled" : "active",
+ })
+ }
+ >
+ {row.definition.status === "active" ? (
+
+ ) : (
+
+ )}
+ {row.definition.status === "active" ? "Disable" : "Activate"}
+
+
+ definitionStatusMutation.mutate({
+ definition: row.definition,
+ status: row.definition.status === "archived" ? "active" : "archived",
+ })
+ }
+ >
+ {row.definition.status === "archived" ? (
+
+ ) : (
+
+ )}
+ {row.definition.status === "archived" ? "Unarchive" : "Archive"}
+
+
+ setDefinitionDeleteConfirm(row.definition)}>
+ Delete definition
+
+ >
+ )}
+
+
+ );
+ }
+
if (!selectedCompanyId) {
return (
Select a company to manage secrets.
@@ -872,6 +1431,7 @@ export function Secrets() {
}
return (
+
@@ -886,6 +1446,7 @@ export function Secrets() {
setActiveTab("vaults")}
className="ml-auto"
/>
- setCreateOpen(true)} size="sm">
+
New secret
- {secretsQuery.isError ? (
+ {secretsQuery.isError || userDefinitionsQuery.isError ? (
Failed to load secrets:{" "}
- {(secretsQuery.error as Error).message}
-
secretsQuery.refetch()}>
+ {((secretsQuery.error ?? userDefinitionsQuery.error) as Error).message}
+ {
+ void secretsQuery.refetch();
+ void userDefinitionsQuery.refetch();
+ }}
+ >
Retry
- ) : secrets.length === 0 && !secretsQuery.isPending ? (
+ ) : unifiedRows.length === 0 && !secretsQuery.isPending && !userDefinitionsQuery.isPending ? (
setCreateOpen(true)}
+ onAction={openCreateSecret}
/>
- ) : filtered.length === 0 ? (
+ ) : filteredRows.length === 0 ? (
) : (
-
-
-
- Name
- Mode
- Provider
- Status
- Version
- Last rotated
- Last resolved
- References
- Reference
-
-
-
-
- {filtered.map((secret) => (
- setSelectedSecretId(secret.id)}
+
+
+
-
- {secret.name}
-
-
- {modeLabel(secret.managedMode)}
-
-
- {providerLabel(providers, secret.provider)}
-
-
-
- {secret.status}
-
-
-
v{secret.latestVersion}
-
- {formatRelative(secret.lastRotatedAt)}
-
-
- {formatRelative(secret.lastResolvedAt)}
-
-
- {
- event.stopPropagation();
- setUsageDialogSecretId(secret.id);
+ Secret
+ Status
+ Version / coverage
+ Updated
+ Actions
+
+
+ {filteredRows.map((row) => {
+ const status = row.kind === "company" ? row.secret.status : row.definition.status;
+ const updatedAt = row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt;
+ const updatedTooltip =
+ row.kind === "company"
+ ? [
+ `Updated: ${formatRelative(row.secret.updatedAt)}`,
+ `Last rotated: ${formatRelative(row.secret.lastRotatedAt)}`,
+ `Last resolved: ${formatRelative(row.secret.lastResolvedAt)}`,
+ ].join("\n")
+ : `Updated: ${formatRelative(row.definition.updatedAt)}\nLast resolved: user values resolve per member`;
+ return (
+
{
+ if (row.kind === "company") openCompanySecret(row.secret);
+ else openUserDefinition(row.definition);
+ }}
+ >
+
+
+
+ {row.kind === "company" ? row.secret.name : row.definition.name}
+
+ {row.kind === "company" ? (
+
+ ) : (
+
+
+
+
+
+
+ Each user provides and owns their own value
+
+ )}
+
+
+ {row.kind === "company" ? row.secret.key : row.definition.key}
+
+
+ {row.kind === "company" ? (
+
+ Company
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {row.kind === "company" ? (
+
+ v{row.secret.latestVersion}
+ · {row.secret.managedMode === "external_reference" ? "linked" : "managed"}
+
+ ) : (
+
+ )}
+
+
+
+
+
event.stopPropagation()}>
+ {renderRowActions(row)}
+
+
+ );
+ })}
+
+
+
+
+ {filteredRows.map((row) => {
+ const status = row.kind === "company" ? row.secret.status : row.definition.status;
+ return (
+
{
+ if (row.kind === "company") openCompanySecret(row.secret);
+ else openUserDefinition(row.definition);
}}
>
- {secret.referenceCount ?? 0}
-
-
-
- {secret.managedMode === "external_reference" ? (
-
-
- {secret.externalRef ?? "—"}
-
- ) : (
- Owned
- )}
-
-
- {
- event.stopPropagation();
- setSelectedSecretId(secret.id);
- }}
- >
- Open
-
-
-
- ))}
-
-
+
+
+
+ {row.kind === "company" ? row.secret.name : row.definition.name}
+
+
+ {row.kind === "company" ? row.secret.key : row.definition.key}
+
+
+
event.stopPropagation()}>{renderRowActions(row)}
+
+
+ {row.kind === "company" ? (
+ <>
+
+ Company
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ {row.kind === "company" ? (
+ <>
+ v{row.secret.latestVersion} ·{" "}
+ {row.secret.managedMode === "external_reference" ? "linked" : "managed"}
+ >
+ ) : (
+ "Member-owned values"
+ )}
+
+ Updated {formatRelative(row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt)}
+
+
+ );
+ })}
+
+
)}
+
+
+
- !open && setSelectedSecretId(null)}>
+ {
+ if (!open) {
+ setSelectedSecretId(null);
+ setSelectedDefinitionId(null);
+ }
+ }}
+ >
{selectedSecret ? (
<>
-
-
-
- {selectedSecret.name}
-
- {selectedSecret.status}
+
+
+
+ {selectedSecret.name}
+
+
-
- {providerLabel(providers, selectedSecret.provider)} · v{selectedSecret.latestVersion} · {modeLabel(selectedSecret.managedMode)}
+
+ {providerLabel(providers, selectedSecret.provider)} secret {selectedSecret.key}
+
+
+ {selectedSecret.key}
+
+ copySecretKey(selectedSecret.key)}
+ >
+ Copy
+
+
+
+
+ Company
+
+ {modeLabel(selectedSecret.managedMode)}
+ {providerLabel(providers, selectedSecret.provider)}
+ v{selectedSecret.latestVersion}
+
-
+
{
- setRotateOpen(true);
- setRotateValue("");
- setRotateExternalRef("");
- setRotateProviderConfigId(
- selectedSecret.providerConfigId ??
- getDefaultProviderConfigId(providerConfigs, selectedSecret.provider),
- );
- setRotateError(null);
- }}
+ onClick={() => openRotateSecret(selectedSecret)}
>
{selectedSecret.managedMode === "external_reference" ? "Update reference" : "Update value"}
- {selectedSecret.status === "active" ? (
-
statusMutation.mutate({ id: selectedSecret.id, status: "disabled" })}
- disabled={statusMutation.isPending}
- >
- Disable
-
- ) : (
-
statusMutation.mutate({ id: selectedSecret.id, status: "active" })}
- disabled={statusMutation.isPending}
- >
- Activate
-
- )}
- {selectedSecret.status === "archived" ? (
-
statusMutation.mutate({ id: selectedSecret.id, status: "active" })}
- disabled={statusMutation.isPending}
- >
- Unarchive
-
- ) : (
-
statusMutation.mutate({ id: selectedSecret.id, status: "archived" })}
- disabled={statusMutation.isPending}
- >
- Archive
-
- )}
-
setDeleteConfirm(selectedSecret)}
- >
- Delete
-
+
+
+
+ More
+
+
+
+
+ statusMutation.mutate({
+ id: selectedSecret.id,
+ status: selectedSecret.status === "active" ? "disabled" : "active",
+ })
+ }
+ >
+ {selectedSecret.status === "active" ? (
+
+ ) : (
+
+ )}
+ {selectedSecret.status === "active" ? "Disable" : "Activate"}
+
+
+ statusMutation.mutate({
+ id: selectedSecret.id,
+ status: selectedSecret.status === "archived" ? "active" : "archived",
+ })
+ }
+ >
+ {selectedSecret.status === "archived" ? (
+
+ ) : (
+
+ )}
+ {selectedSecret.status === "archived" ? "Unarchive" : "Archive"}
+
+
+ setDeleteConfirm(selectedSecret)}>
+ Delete secret
+
+
+
@@ -1154,13 +1835,156 @@ export function Secrets() {
-
+ setSecretDetailTab("usage")}
+ />
-
+
+
+
+
+ >
+ ) : selectedDefinition ? (
+ <>
+
+
+
+ {selectedDefinition.name}
+
+
+
+
+
+ Each user secret definition {selectedDefinition.key}
+
+
+
+ {selectedDefinition.key}
+
+ copySecretKey(selectedDefinition.key)}
+ >
+ Copy
+
+
+
+
+
+
+
+
+
+
+
+ setSetMyValueFor(
+ selectedDefinitionMyEntry ?? { definition: selectedDefinition, secret: null },
+ )
+ }
+ disabled={selectedDefinition.status !== "active"}
+ >
+
+ {selectedDefinitionMyEntry?.secret ? "Update my value" : "Set my value"}
+
+
+
+
+ More
+
+
+
+ openEditDefinition(selectedDefinition)}>
+ Edit definition
+
+
+
+ definitionStatusMutation.mutate({
+ definition: selectedDefinition,
+ status: selectedDefinition.status === "active" ? "disabled" : "active",
+ })
+ }
+ >
+ {selectedDefinition.status === "active" ? (
+
+ ) : (
+
+ )}
+ {selectedDefinition.status === "active" ? "Disable" : "Activate"}
+
+
+ definitionStatusMutation.mutate({
+ definition: selectedDefinition,
+ status: selectedDefinition.status === "archived" ? "active" : "archived",
+ })
+ }
+ >
+ {selectedDefinition.status === "archived" ? (
+
+ ) : (
+
+ )}
+ {selectedDefinition.status === "archived" ? "Unarchive" : "Archive"}
+
+
+ setDefinitionDeleteConfirm(selectedDefinition)}>
+ Delete definition
+
+
+
+
+
+
+
+
+ setSecretDetailTab("coverage")}
+ />
+
+
+
+
+
+
+
+
+
@@ -1214,123 +2038,202 @@ export function Secrets() {
)}
-
+
- Create secret
+ {editingDefinition ? "Edit user-provided secret" : "Create secret"}
- Choose whether Paperclip should own future provider writes, or only resolve an existing
- provider reference at runtime.
+ Choose who provides the value. Shared fields keep their values when you switch modes.
- setCreateMode(value as CreateMode)}>
-
- Managed value
- External reference
-
-
-
+
- Provider
-
+ Description (optional)
+
+
- setCreateForm((current) => {
- const provider = event.target.value as SecretProvider;
- return {
+ setCreateForm((current) => ({ ...current, description: event.target.value }))
+ }
+ placeholder="What is this secret used for? (no values)"
+ />
+
+
+ {!editingDefinition ? (
+
+
Who provides the value?
+
{
+ const next = value as SecretValueProvider;
+ setSecretValueProvider(next);
+ setCreateForm((current) => ({
...current,
- provider,
- providerConfigId: getDefaultProviderConfigId(providerConfigs, provider),
- };
- })
- }
- >
- {providers.map((provider) => (
-
- {provider.label}
- {provider.configured === false
- ? " (not configured)"
- : provider.requiresExternalRef
- ? " (external only)"
- : ""}
-
- ))}
-
- {createProviderBlockReason ? (
-
-
- {createProviderBlockReason}
+ key: createKeyDirty
+ ? current.key
+ : next === "user"
+ ? normalizeUserSecretKeyForPreview(current.name)
+ : normalizeSecretKeyForPreview(current.name),
+ }));
+ }}
+ >
+
+ Company
+ Each user
+
+
+
+ Company stores one shared value. Each user lets every member supply their own value under My secrets.
- ) : createProviderHealthText ? (
-
{createProviderHealthText}
- ) : null}
-
-
-
Provider vault
-
- setCreateForm((current) => ({ ...current, providerConfigId: event.target.value }))
- }
- >
- Deployment default
- {createProviderConfigs.map((config) => {
- const blockReason = getProviderConfigBlockReason(config);
- return (
-
- {config.displayName}
- {config.isDefault ? " (default)" : ""}
- {blockReason ? ` (${blockReason})` : ""}
-
- );
- })}
-
- {selectedCreateProviderConfig ? (
-
- ) : (
-
- Existing deployment-level provider settings stay available for backwards compatibility.
-
- )}
-
- {createMode === "managed" ? (
+
+ ) : null}
+
+ {secretValueProvider === "company" ? (
<>
+
setCreateMode(value as CreateMode)}>
+
+
+ Managed value
+
+
+ External reference
+
+
+
+
+
Provider
+
+ setCreateForm((current) => {
+ const provider = event.target.value as SecretProvider;
+ return {
+ ...current,
+ provider,
+ providerConfigId: getDefaultProviderConfigId(providerConfigs, provider),
+ };
+ })
+ }
+ >
+ {providers.map((provider) => (
+
+ {provider.label}
+ {provider.configured === false &&
+ !getSelectableProviderConfig(providerConfigs, provider.id)
+ ? " (deployment default missing)"
+ : provider.requiresExternalRef
+ ? " (external only)"
+ : ""}
+
+ ))}
+
+ {createProviderBlockReason ? (
+
+
+ {createProviderBlockReason}
+
+ ) : createProviderHealthText ? (
+
{createProviderHealthText}
+ ) : null}
+
+
+
Provider vault
+
+ setCreateForm((current) => ({ ...current, providerConfigId: event.target.value }))
+ }
+ >
+ Deployment default
+ {createProviderConfigs.map((config) => {
+ const blockReason = getProviderConfigBlockReason(config);
+ return (
+
+ {config.displayName}
+ {config.isDefault ? " (default)" : ""}
+ {blockReason ? ` (${blockReason})` : ""}
+
+ );
+ })}
+
+ {selectedCreateProviderConfig ? (
+
+ ) : (
+
+ Existing deployment-level provider settings stay available for backwards compatibility.
+
+ )}
+
+ {createMode === "managed" ? (
+ <>
Paperclip-managed secrets are created in the selected provider and future rotations
write a new provider version through Paperclip.
@@ -1356,38 +2259,48 @@ export function Secrets() {
placeholder="Stored once, never re-displayed"
/>
+ >
+ ) : (
+
+
External reference
+
+ setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
+ }
+ placeholder="arn:aws:secretsmanager:..."
+ className="font-mono text-xs"
+ />
+
+ Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
+ then update this reference only if the path, ARN, or version changes.
+
+
+ )}
>
) : (
-
-
External reference
-
- setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
- }
- placeholder="arn:aws:secretsmanager:..."
- className="font-mono text-xs"
- />
-
- Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
- then update this reference only if the path, ARN, or version changes.
-
-
+ <>
+
+ Every member supplies their own value under My secrets. Agents resolve the responsible
+ user's value at runtime.
+
+
+
+ Usage guidance (optional)
+
+
+ >
)}
-
-
- Description (optional)
-
-
- setCreateForm((current) => ({ ...current, description: event.target.value }))
- }
- placeholder="What is this secret used for? (no values)"
- />
-
{createError ?
{createError}
: null}
@@ -1401,13 +2314,21 @@ export function Secrets() {
}}
disabled={
createMutation.isPending ||
- Boolean(createProviderBlockReason) ||
!createForm.name.trim() ||
- (createMode === "managed" ? !createForm.value : !createForm.externalRef.trim())
+ (secretValueProvider === "user"
+ ? !createForm.key.trim()
+ : Boolean(createProviderBlockReason) ||
+ (createMode === "managed" ? !createForm.value : !createForm.externalRef.trim()))
}
>
{createMutation.isPending ? : null}
- {createMode === "managed" ? "Create secret" : "Link reference"}
+ {editingDefinition
+ ? "Save changes"
+ : secretValueProvider === "user"
+ ? "Create user-provided secret"
+ : createMode === "managed"
+ ? "Create secret"
+ : "Link reference"}
@@ -1656,6 +2577,44 @@ export function Secrets() {
+
!open && setDefinitionDeleteConfirm(null)}
+ >
+
+
+ Delete user-provided secret
+
+ Permanently removes {definitionDeleteConfirm?.name} for the whole company.
+ Existing member values become unreferenced and active bindings must be remapped.
+
+
+
+ setDefinitionDeleteConfirm(null)}>Cancel
+
+ definitionDeleteConfirm && deleteDefinitionMutation.mutate(definitionDeleteConfirm)
+ }
+ disabled={deleteDefinitionMutation.isPending}
+ >
+ {deleteDefinitionMutation.isPending ? : null}
+ Delete
+
+
+
+
+
+
{
+ if (!open) setSetMyValueFor(null);
+ }}
+ />
+
!open && setRemoveVaultConfirm(null)}>
@@ -1682,6 +2641,7 @@ export function Secrets() {
+
);
}
@@ -1708,21 +2668,26 @@ function SecretsHowToUse() {
function SecretsFiltersPopover({
statusFilter,
providerFilter,
+ providedByFilter,
providers,
activeFilterCount,
onStatusChange,
onProviderChange,
+ onProvidedByChange,
}: {
statusFilter: SecretStatus | "all";
providerFilter: SecretProvider | "all";
+ providedByFilter: ProvidedByFilter;
providers: SecretProviderDescriptor[];
activeFilterCount: number;
onStatusChange: (value: SecretStatus | "all") => void;
onProviderChange: (value: SecretProvider | "all") => void;
+ onProvidedByChange: (value: ProvidedByFilter) => void;
}) {
const resetFilters = () => {
onStatusChange("active");
onProviderChange("all");
+ onProvidedByChange("all");
};
const statusOptions: Array<{ value: SecretStatus | "all"; label: string }> = [
@@ -1768,7 +2733,7 @@ function SecretsFiltersPopover({
) : null}
-
+
Status
@@ -1784,6 +2749,25 @@ function SecretsFiltersPopover({
+
+
Provided by
+
+ {[
+ { value: "all" as const, label: "All sources" },
+ { value: "company" as const, label: "Company" },
+ { value: "user" as const, label: "Each user" },
+ ].map((option) => (
+
+ onProvidedByChange(option.value)}
+ />
+ {option.label}
+
+ ))}
+
+
+
Provider
@@ -2475,37 +3459,207 @@ function TextField({
);
}
-function SecretDetailsTab({
- secret,
- providerConfigs,
+function CoverageInline({
+ companyId,
+ definitionId,
+ compact = false,
}: {
- secret: CompanySecret;
- providerConfigs: CompanySecretProviderConfig[];
+ companyId: string;
+ definitionId: string;
+ compact?: boolean;
+}) {
+ const coverageQuery = useQuery({
+ queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId),
+ queryFn: () => secretsApi.userSecretDefinitionCoverage(companyId, definitionId),
+ staleTime: 30_000,
+ });
+ const summary = coverageQuery.data;
+ if (coverageQuery.isPending) return
Loading… ;
+ if (coverageQuery.isError) return
Coverage unavailable ;
+ return (
+
+
+
+ {compact && summary
+ ? `${summary.configuredCount}/${summary.configuredCount + summary.missingCount + summary.inactiveCount} set`
+ : coverageSummaryLabel(summary)}
+
+ {summary && summary.missingCount > 0 ? (
+
+ · {compact ? `${summary.missingCount} miss` : `${summary.missingCount} missing`}
+
+ ) : null}
+
+ );
+}
+
+function UserSecretDetailsTab({
+ companyId,
+ definition,
+ onViewCoverage,
+}: {
+ companyId: string;
+ definition: UserSecretDefinition;
+ onViewCoverage: () => void;
}) {
return (
-
+
+
+ {definition.description ?? — }
+
+ Each user
+
+ {definition.key}
+
+
+
+
+
+ · View in Coverage
+
+
+ {formatRelative(definition.createdAt)}
+ {formatRelative(definition.updatedAt)}
+
+ {definition.usageGuidance ?? — }
+
+
+ No value is stored on this admin row. Each member manages their own value under My secrets.
+
+
+ );
+}
+
+function UserSecretCoverageTab({
+ companyId,
+ definitionId,
+}: {
+ companyId: string;
+ definitionId: string;
+}) {
+ const coverageQuery = useQuery({
+ queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId),
+ queryFn: () => secretsApi.userSecretDefinitionCoverage(companyId, definitionId),
+ staleTime: 30_000,
+ });
+ if (coverageQuery.isPending) {
+ return Loading…
;
+ }
+ if (coverageQuery.isError) {
+ return Coverage unavailable.
;
+ }
+ const summary: UserSecretCoverageSummary = coverageQuery.data;
+ const total = summary.configuredCount + summary.missingCount + summary.inactiveCount;
+ return (
+
+
+
+ {coverageSummaryLabel(summary)}
+
+
+
+
+ {summary.configuredCount}
+
+
Set
+
+
+
+ {summary.missingCount}
+
+
Missing
+
+
+
+ {summary.inactiveCount}
+
+
Inactive
+
+
+
+ Coverage is counts only across {total} member{total === 1 ? "" : "s"}. Secret values are never shown here.
+
+
+ );
+}
+
+function UserSecretUsageTab({ definition }: { definition: UserSecretDefinition }) {
+ return (
+
+
+ Bind runtime environment variables to this user-provided secret by choosing{" "}
+ User secret and selecting{" "}
+ {definition.key}.
+
+ {definition.usageGuidance ? (
+
+
Member guidance
+
{definition.usageGuidance}
+
+ ) : null}
+
+ );
+}
+
+function UserSecretAccessEventsTab() {
+ return (
+
+ Access events are recorded on each member's stored value when runtime resolution occurs.
+
+ );
+}
+
+function SecretDetailsTab({
+ secret,
+ providers,
+ providerConfigs,
+ onViewUsage,
+}: {
+ secret: CompanySecret;
+ providers: SecretProviderDescriptor[];
+ providerConfigs: CompanySecretProviderConfig[];
+ onViewUsage: () => void;
+}) {
+ const bindingLabel = (secret.referenceCount ?? 0) === 1
+ ? "1 binding"
+ : `${secret.referenceCount ?? 0} bindings`;
+
+ return (
+
{secret.description ?? — }
+ Company
{modeLabel(secret.managedMode)}
- {secret.provider.replaceAll("_", " ")}
+ {providerLabel(providers, secret.provider)}
{providerVaultLabel(providerConfigs, secret.providerConfigId)}
+
+ {secret.externalRef ? (
+ {secret.externalRef}
+ ) : (
+ —
+ )}
+
v{secret.latestVersion}
+
+
+ {bindingLabel}
+ · View in Usage
+
+
{formatRelative(secret.createdAt)}
{formatRelative(secret.updatedAt)}
{formatRelative(secret.lastRotatedAt)}
{formatRelative(secret.lastResolvedAt)}
- {secret.externalRef ? (
-
-
- {secret.managedMode === "external_reference" ? "Linked provider reference" : "Provider-managed path"}
-
-
- {secret.externalRef}
-
-
- ) : null}
-
+
{modeDescription(secret.managedMode)} Paperclip never re-displays stored values.
@@ -2514,9 +3668,9 @@ function SecretDetailsTab({
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
-
+
{label}
- {children}
+ {children}
);
}
@@ -2569,7 +3723,34 @@ function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: Com
);
}
-function SecretEventsTab({ loading, events }: { loading: boolean; events: SecretAccessEvent[] }) {
+function SecretEventsTab({
+ loading,
+ events,
+ companyId,
+}: {
+ loading: boolean;
+ events: SecretAccessEvent[];
+ companyId: string;
+}) {
+ // Resolve responsible/owner user ids to human names for user-scoped events.
+ const anyUserScoped = events.some(
+ (event) =>
+ event.secretScope === "user" || event.responsibleUserId || event.credentialOwnerUserId,
+ );
+ const { data: directory } = useQuery({
+ queryKey: queryKeys.access.companyUserDirectory(companyId),
+ queryFn: () => accessApi.listUserDirectory(companyId),
+ enabled: anyUserScoped,
+ staleTime: 60_000,
+ });
+ const userLabel = (userId: string | null): string => {
+ if (!userId) return "—";
+ const entry: CompanyUserDirectoryEntry | undefined = directory?.users.find(
+ (u) => u.principalId === userId,
+ );
+ return entry?.user?.name?.trim() || entry?.user?.email?.trim() || `${userId.slice(0, 8)}…`;
+ };
+
if (loading) {
return
Loading…
;
}
@@ -2584,15 +3765,34 @@ function SecretEventsTab({ loading, events }: { loading: boolean; events: Secret
{events.map((event) => (
-
-
+
+
{event.consumerType} · {event.outcome}
+ {event.secretScope === "user" ? (
+
+ User secret
+
+ ) : null}
{formatRelative(event.createdAt)}
{event.consumerId}
+ {event.responsibleUserId ? (
+
+ Responsible user: {userLabel(event.responsibleUserId)}
+
+ ) : null}
+ {event.credentialOwnerUserId &&
+ event.credentialOwnerUserId !== event.responsibleUserId ? (
+
+ Credential owner: {userLabel(event.credentialOwnerUserId)}
+
+ ) : null}
{event.errorCode ? (
{event.errorCode}
) : null}
diff --git a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx
index ea869eab37..be0045d2fa 100644
--- a/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx
+++ b/ui/src/pages/secrets/ImportFromVaultDialog.test.tsx
@@ -286,6 +286,9 @@ describe("ImportFromVaultDialog", () => {
{
id: "secret-existing",
companyId: "company-1",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "openai_api_key",
name: "OPENAI_API_KEY",
provider: "aws_secrets_manager",
diff --git a/ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx b/ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx
new file mode 100644
index 0000000000..ea79ce5500
--- /dev/null
+++ b/ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx
@@ -0,0 +1,176 @@
+// @vitest-environment jsdom
+
+import { createRoot, type Root } from "react-dom/client";
+import { flushSync } from "react-dom";
+import { MemoryRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MissingUserSecretsBanner } from "./MissingUserSecretsBanner";
+import type { MyUserSecretEntry } from "../../api/secrets";
+
+const mockSecretsApi = vi.hoisted(() => ({
+ listMyUserSecrets: vi.fn(),
+ createMyUserSecret: vi.fn(),
+ rotateMyUserSecret: vi.fn(),
+}));
+const mockPushToast = vi.hoisted(() => vi.fn());
+
+vi.mock("../../api/secrets", () => ({ secretsApi: mockSecretsApi }));
+vi.mock("../../context/ToastContext", () => ({
+ useToastActions: () => ({ pushToast: mockPushToast }),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+function definition(overrides: Partial = {}): UserSecretDefinition {
+ return {
+ id: "def-1",
+ companyId: "c1",
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GitHub token",
+ description: "Used for private repo access",
+ status: "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ updatedByAgentId: null,
+ updatedByUserId: null,
+ deletedAt: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ };
+}
+
+function secret(): CompanySecret {
+ return {
+ id: "sec-1",
+ companyId: "c1",
+ scope: "user",
+ ownerUserId: "u1",
+ userSecretDefinitionId: "def-1",
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GitHub token",
+ provider: "local_encrypted",
+ status: "active",
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 1,
+ description: null,
+ lastResolvedAt: null,
+ lastRotatedAt: null,
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "u1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ root = createRoot(container);
+ return act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+}
+
+describe("MissingUserSecretsBanner", () => {
+ it("warns about an active user secret with no value and offers to set it", async () => {
+ const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: null }];
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
+
+ await render();
+ await flushReact();
+
+ expect(container.textContent).toContain("Personal GitHub token");
+ expect(container.textContent).toContain("PERSONAL_GH_TOKEN");
+ expect(container.textContent).toContain("Set value");
+ expect(container.textContent?.toLowerCase()).toContain("will fail");
+ });
+
+ it("renders nothing when every active secret already has a value", async () => {
+ const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: secret() }];
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
+
+ await render();
+ await flushReact();
+
+ expect(container.textContent).toBe("");
+ });
+
+ it("ignores disabled definitions", async () => {
+ const entries: MyUserSecretEntry[] = [
+ { definition: definition({ status: "disabled" }), secret: null },
+ ];
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
+
+ await render();
+ await flushReact();
+
+ expect(container.textContent).toBe("");
+ });
+
+ it("opens the set-value dialog when Set value is clicked", async () => {
+ const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: null }];
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries);
+
+ await render();
+ await flushReact();
+
+ const setButton = Array.from(container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Set value"),
+ );
+ expect(setButton).toBeTruthy();
+ await act(() => setButton!.click());
+ await flushReact();
+
+ // Dialog content renders in a portal on document.body.
+ expect(document.body.textContent).toContain("Set your value");
+ });
+});
diff --git a/ui/src/pages/secrets/MissingUserSecretsBanner.tsx b/ui/src/pages/secrets/MissingUserSecretsBanner.tsx
new file mode 100644
index 0000000000..68ebfe8521
--- /dev/null
+++ b/ui/src/pages/secrets/MissingUserSecretsBanner.tsx
@@ -0,0 +1,106 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { AlertTriangle } from "lucide-react";
+import { Link } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { secretsApi, type MyUserSecretEntry } from "../../api/secrets";
+import { queryKeys } from "../../lib/queryKeys";
+import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog";
+
+/**
+ * Warning surface for user secrets the current user has not yet set. Renders
+ * nothing when there is nothing missing, so it is safe to embed on task
+ * creation / run and issue-failure surfaces. Lets the user satisfy a missing
+ * required secret inline via the shared value dialog.
+ *
+ * Pass `definitionKeys` to scope the warning to a specific set (e.g. the user
+ * secrets a blocked run reported as missing); omit it to warn about every
+ * active definition the user has not set.
+ */
+export function MissingUserSecretsBanner({
+ companyId,
+ definitionKeys,
+ title = "Set your user secrets",
+ secretsPath,
+ className,
+}: {
+ companyId: string;
+ definitionKeys?: string[];
+ title?: string;
+ /** Optional route to the Secrets → My secrets tab for the "Manage all" link. */
+ secretsPath?: string;
+ className?: string;
+}) {
+ const [dialogFor, setDialogFor] = useState(null);
+
+ const mySecretsQuery = useQuery({
+ queryKey: queryKeys.secrets.myUserSecrets(companyId),
+ queryFn: () => secretsApi.listMyUserSecrets(companyId),
+ retry: false,
+ });
+
+ const keyFilter = definitionKeys ? new Set(definitionKeys) : null;
+ const missing = (mySecretsQuery.data ?? []).filter(
+ (entry) =>
+ entry.definition.status === "active" &&
+ !entry.secret &&
+ (!keyFilter || keyFilter.has(entry.definition.key)),
+ );
+
+ if (missing.length === 0) return null;
+
+ return (
+
+
+
+
+
{title}
+
+ {missing.length} user secret{missing.length === 1 ? "" : "s"} you are responsible for
+ {missing.length === 1 ? " has" : " have"} no value yet. Runs that require
+ {missing.length === 1 ? " it" : " them"} will fail until you set your value.
+
+
+ {missing.map((entry) => (
+
+
+ {entry.definition.name} {" "}
+ {entry.definition.key}
+
+ setDialogFor(entry)}>
+ Set value
+
+
+ ))}
+
+ {secretsPath ? (
+
+ Manage all my secrets
+
+ ) : null}
+
+
+
+
{
+ if (!open) setDialogFor(null);
+ }}
+ />
+
+ );
+}
diff --git a/ui/src/pages/secrets/MyUserSecretsTab.tsx b/ui/src/pages/secrets/MyUserSecretsTab.tsx
new file mode 100644
index 0000000000..e18802231c
--- /dev/null
+++ b/ui/src/pages/secrets/MyUserSecretsTab.tsx
@@ -0,0 +1,181 @@
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { CompanySecret } from "@paperclipai/shared";
+import { AlertCircle, KeyRound, Trash2, UserRound } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { EmptyState } from "../../components/EmptyState";
+import { secretsApi, type MyUserSecretEntry } from "../../api/secrets";
+import { queryKeys } from "../../lib/queryKeys";
+import { cn } from "../../lib/utils";
+import { useToastActions } from "../../context/ToastContext";
+import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog";
+import {
+ myValueLabel,
+ myValueState,
+ myValueTone,
+} from "./my-value-state";
+
+/**
+ * Secrets → My secrets tab. Lists every company user-secret definition paired
+ * with the current user's own value state, and lets the user set / update /
+ * clear their value. This is the owner-facing counterpart to the admin
+ * "User secret definitions" tab.
+ */
+export function MyUserSecretsTab({ companyId }: { companyId: string }) {
+ const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+ const [dialogFor, setDialogFor] = useState(null);
+
+ const mySecretsQuery = useQuery({
+ queryKey: queryKeys.secrets.myUserSecrets(companyId),
+ queryFn: () => secretsApi.listMyUserSecrets(companyId),
+ });
+ const entries = mySecretsQuery.data ?? [];
+
+ const clear = useMutation({
+ mutationFn: (secret: CompanySecret) => secretsApi.removeMyUserSecret(companyId, secret.id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(companyId) });
+ pushToast({ title: "Value cleared", tone: "info" });
+ },
+ onError: (err) =>
+ pushToast({
+ title: "Could not clear value",
+ body: err instanceof Error ? err.message : undefined,
+ tone: "error",
+ }),
+ });
+
+ const missingCount = entries.filter(
+ (entry) => entry.definition.status === "active" && !entry.secret,
+ ).length;
+
+ return (
+
+
+
+
+ These are credentials only you provide. Each value is yours alone — used when you are the
+ user responsible for a run — and is never shown back to anyone, including admins.
+ {missingCount > 0 ? (
+
+ {" "}
+ {missingCount} required secret{missingCount === 1 ? " still needs" : "s still need"} your
+ value.
+
+ ) : null}
+
+
+
+
+ {mySecretsQuery.isError ? (
+
+
Failed to load your secrets:{" "}
+ {(mySecretsQuery.error as Error).message}
+
mySecretsQuery.refetch()}>
+ Retry
+
+
+ ) : entries.length === 0 && !mySecretsQuery.isPending ? (
+
+ ) : (
+
+ {entries.map((entry) => (
+ setDialogFor(entry)}
+ onClear={() => entry.secret && clear.mutate(entry.secret)}
+ clearing={clear.isPending}
+ />
+ ))}
+
+ )}
+
+
+
{
+ if (!open) setDialogFor(null);
+ }}
+ />
+
+ );
+}
+
+function MyUserSecretRow({
+ entry,
+ onSet,
+ onClear,
+ clearing,
+}: {
+ entry: MyUserSecretEntry;
+ onSet: () => void;
+ onClear: () => void;
+ clearing: boolean;
+}) {
+ const { definition, secret } = entry;
+ const state = myValueState(definition, secret);
+ const disabledDefinition = definition.status !== "active";
+
+ return (
+
+
+
+ {definition.name}
+
+ {definition.key}
+
+ {disabledDefinition ? (
+
+ {definition.status}
+
+ ) : null}
+
+ {definition.description ? (
+
{definition.description}
+ ) : null}
+ {definition.usageGuidance ? (
+
{definition.usageGuidance}
+ ) : null}
+
+
+
+
+ {myValueLabel(state)}
+
+ {!disabledDefinition ? (
+
+ {secret ? "Update" : "Set value"}
+
+ ) : null}
+ {secret ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/ui/src/pages/secrets/SetMyUserSecretDialog.tsx b/ui/src/pages/secrets/SetMyUserSecretDialog.tsx
new file mode 100644
index 0000000000..77a98f5d96
--- /dev/null
+++ b/ui/src/pages/secrets/SetMyUserSecretDialog.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { secretsApi } from "../../api/secrets";
+import { ApiError } from "../../api/client";
+import { queryKeys } from "../../lib/queryKeys";
+import { useToastActions } from "../../context/ToastContext";
+import { UserSecretChip } from "./user-secret-presentation";
+
+/**
+ * Shared "set my value" dialog for a user-secret definition. Used both from the
+ * Secrets → My secrets tab and from the missing-required-secret warning surfaces
+ * (task run / issue failure), so a user can satisfy a required secret from either
+ * place with identical behavior.
+ */
+export function SetMyUserSecretDialog({
+ companyId,
+ definition,
+ existingSecret,
+ open,
+ onOpenChange,
+ onSaved,
+}: {
+ companyId: string;
+ definition: UserSecretDefinition | null;
+ existingSecret?: CompanySecret | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSaved?: (secret: CompanySecret) => void;
+}) {
+ const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+ const [value, setValue] = useState("");
+ const [externalRef, setExternalRef] = useState("");
+ const [error, setError] = useState(null);
+
+ const isExternal = definition?.managedMode === "external_reference";
+
+ useEffect(() => {
+ if (open) {
+ setValue("");
+ setExternalRef("");
+ setError(null);
+ }
+ }, [open, definition?.id]);
+
+ const save = useMutation({
+ mutationFn: async () => {
+ if (!definition) throw new Error("No definition selected");
+ const payload = isExternal
+ ? { externalRef: externalRef.trim() }
+ : { value: value.trim() };
+ if (existingSecret) {
+ // A stored value already exists → rotate it in place.
+ return secretsApi.rotateMyUserSecret(companyId, existingSecret.id, payload);
+ }
+ return secretsApi.createMyUserSecret(companyId, {
+ definitionId: definition.id,
+ definitionKey: definition.key,
+ ...payload,
+ });
+ },
+ onSuccess: (secret) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(companyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) });
+ pushToast({
+ title: existingSecret ? "Value updated" : "Value saved",
+ body: definition?.name,
+ tone: "success",
+ });
+ onSaved?.(secret);
+ onOpenChange(false);
+ },
+ onError: (err) => {
+ setError(
+ err instanceof ApiError
+ ? err.message
+ : err instanceof Error
+ ? err.message
+ : "Failed to save value",
+ );
+ },
+ });
+
+ const canSave = isExternal ? externalRef.trim().length > 0 : value.trim().length > 0;
+
+ return (
+
+
+
+
+ {existingSecret ? "Update your value" : "Set your value"}
+
+
+
+ {definition ? (
+ <>
+ This value is yours only. It is used when you are the user responsible for a run that
+ needs {definition.key} .
+ >
+ ) : null}
+
+
+
+ {definition ? (
+
+
+
{definition.name}
+ {definition.description ? (
+
{definition.description}
+ ) : null}
+ {definition.usageGuidance ? (
+
{definition.usageGuidance}
+ ) : null}
+
+
+ {isExternal ? (
+
+
External reference
+
setExternalRef(event.target.value)}
+ placeholder="provider reference or ARN"
+ className="font-mono text-sm"
+ autoFocus
+ />
+
+ Points at your own credential in the configured provider. Paperclip stores the
+ reference, not the value.
+
+
+ ) : (
+
+ )}
+
+ {error ?
{error}
: null}
+
+ ) : null}
+
+
+ onOpenChange(false)} disabled={save.isPending}>
+ Cancel
+
+ save.mutate()} disabled={!canSave || save.isPending}>
+ {save.isPending ? "Saving…" : existingSecret ? "Update value" : "Save value"}
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/secrets/UserSecretDefinitionsTab.tsx b/ui/src/pages/secrets/UserSecretDefinitionsTab.tsx
new file mode 100644
index 0000000000..4012995882
--- /dev/null
+++ b/ui/src/pages/secrets/UserSecretDefinitionsTab.tsx
@@ -0,0 +1,386 @@
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { SecretStatus, UserSecretDefinition } from "@paperclipai/shared";
+import { AlertCircle, Pencil, Plus, Trash2, UserRound, Users } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { EmptyState } from "../../components/EmptyState";
+import { secretsApi } from "../../api/secrets";
+import { ApiError } from "../../api/client";
+import { queryKeys } from "../../lib/queryKeys";
+import { cn } from "../../lib/utils";
+import { useToastActions } from "../../context/ToastContext";
+import {
+ coverageSummaryLabel,
+ secretStatusTone,
+ UserSecretChip,
+} from "./user-secret-presentation";
+
+function keyFromName(name: string): string {
+ return name
+ .trim()
+ .toUpperCase()
+ .replace(/[^A-Z0-9_]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .slice(0, 120);
+}
+
+interface DefinitionForm {
+ name: string;
+ key: string;
+ description: string;
+ usageGuidance: string;
+ status: SecretStatus;
+}
+
+const emptyForm: DefinitionForm = {
+ name: "",
+ key: "",
+ description: "",
+ usageGuidance: "",
+ status: "active",
+};
+
+/**
+ * Secrets → User secret definitions tab (admin). Defines the shared credentials
+ * that each member fills in with their own value. Coverage is shown as counts
+ * only — never values — per the UX terminology decisions.
+ */
+export function UserSecretDefinitionsTab({ companyId }: { companyId: string }) {
+ const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(emptyForm);
+ const [keyDirty, setKeyDirty] = useState(false);
+ const [error, setError] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const definitionsQuery = useQuery({
+ queryKey: queryKeys.secrets.userDefinitions(companyId),
+ queryFn: () => secretsApi.listUserSecretDefinitions(companyId),
+ });
+ const definitions = definitionsQuery.data ?? [];
+
+ function openCreate() {
+ setEditing(null);
+ setForm(emptyForm);
+ setKeyDirty(false);
+ setError(null);
+ setDialogOpen(true);
+ }
+
+ function openEdit(definition: UserSecretDefinition) {
+ setEditing(definition);
+ setForm({
+ name: definition.name,
+ key: definition.key,
+ description: definition.description ?? "",
+ usageGuidance: definition.usageGuidance ?? "",
+ status: definition.status,
+ });
+ setKeyDirty(true);
+ setError(null);
+ setDialogOpen(true);
+ }
+
+ const save = useMutation({
+ mutationFn: async () => {
+ const sharedPayload = {
+ name: form.name.trim(),
+ description: form.description.trim() || null,
+ usageGuidance: form.usageGuidance.trim() || null,
+ };
+ if (editing) {
+ return secretsApi.updateUserSecretDefinition(companyId, editing.id, {
+ ...sharedPayload,
+ status: form.status,
+ });
+ }
+ return secretsApi.createUserSecretDefinition(companyId, {
+ ...sharedPayload,
+ key: form.key.trim(),
+ status: form.status === "deleted" ? "active" : form.status,
+ });
+ },
+ onSuccess: (definition) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) });
+ pushToast({
+ title: editing ? "Definition updated" : "Definition created",
+ body: definition.name,
+ tone: "success",
+ });
+ setDialogOpen(false);
+ },
+ onError: (err) =>
+ setError(err instanceof ApiError || err instanceof Error ? err.message : "Failed to save"),
+ });
+
+ const remove = useMutation({
+ mutationFn: (definition: UserSecretDefinition) =>
+ secretsApi.removeUserSecretDefinition(companyId, definition.id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(companyId) });
+ pushToast({ title: "Definition removed", tone: "info" });
+ setDeleteTarget(null);
+ },
+ onError: (err) =>
+ pushToast({
+ title: "Could not remove definition",
+ body: err instanceof Error ? err.message : undefined,
+ tone: "error",
+ }),
+ });
+
+ const canSave = form.name.trim().length > 0 && form.key.trim().length > 0;
+
+ return (
+
+
+
+
+ Define credentials that each member supplies for
+ themselves . You set the shape here; every user enters their own value under My
+ secrets. Coverage shows how many members have set a value — never the values themselves.
+
+
+
+
+
+
+ {definitionsQuery.isError ? (
+
+
Failed to load definitions:{" "}
+ {(definitionsQuery.error as Error).message}
+
definitionsQuery.refetch()}>
+ Retry
+
+
+ ) : definitions.length === 0 && !definitionsQuery.isPending ? (
+
+ ) : (
+
+ {definitions.map((definition) => (
+
+
+
+ {definition.name}
+
+ {definition.key}
+
+
+
+ {definition.status}
+
+
+ {definition.description ? (
+
{definition.description}
+ ) : null}
+
+
+
+
openEdit(definition)}>
+
+
+
setDeleteTarget(definition)}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Create / edit dialog */}
+
+
+
+
+ {editing ? "Edit user secret" : "New user secret"}
+
+
+
+ Members supply their own value for this credential. No value is entered here.
+
+
+
+
+
+ Name
+ {
+ const name = event.target.value;
+ setForm((current) => ({
+ ...current,
+ name,
+ key: keyDirty ? current.key : keyFromName(name),
+ }));
+ }}
+ placeholder="Personal GitHub token"
+ autoFocus
+ />
+
+
+
Key
+
{
+ setKeyDirty(true);
+ setForm((current) => ({ ...current, key: event.target.value }));
+ }}
+ placeholder="PERSONAL_GH_TOKEN"
+ className="font-mono text-sm"
+ disabled={Boolean(editing)}
+ />
+
+ Stable identifier referenced by env bindings. {editing ? "Cannot be changed." : ""}
+
+
+
+ Description
+
+ setForm((current) => ({ ...current, description: event.target.value }))
+ }
+ placeholder="What this credential is for"
+ />
+
+
+
+ Usage guidance (optional)
+
+
+ {editing ? (
+
+ Status
+
+ setForm((current) => ({ ...current, status: status as SecretStatus }))
+ }
+ >
+
+
+
+
+ Active
+ Disabled
+ Archived
+
+
+
+ ) : null}
+ {error ?
{error}
: null}
+
+
+
+ setDialogOpen(false)} disabled={save.isPending}>
+ Cancel
+
+ save.mutate()} disabled={!canSave || save.isPending}>
+ {save.isPending ? "Saving…" : editing ? "Save changes" : "Create"}
+
+
+
+
+
+ {/* Delete confirm */}
+
!open && setDeleteTarget(null)}>
+
+
+ Remove user secret?
+
+ This removes the definition {deleteTarget?.key} for
+ the whole company. Existing member values become unreferenced. This cannot be undone.
+
+
+
+ setDeleteTarget(null)} disabled={remove.isPending}>
+ Cancel
+
+ deleteTarget && remove.mutate(deleteTarget)}
+ disabled={remove.isPending}
+ >
+ {remove.isPending ? "Removing…" : "Remove"}
+
+
+
+
+
+ );
+}
+
+function CoverageBadge({
+ companyId,
+ definitionId,
+}: {
+ companyId: string;
+ definitionId: string;
+}) {
+ const coverageQuery = useQuery({
+ queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId),
+ queryFn: () => secretsApi.userSecretDefinitionCoverage(companyId, definitionId),
+ staleTime: 30_000,
+ });
+ const summary = coverageQuery.data;
+ const missing = summary ? summary.missingCount : 0;
+ return (
+
+
+ Coverage: {coverageSummaryLabel(summary)}
+ {summary && missing > 0 ? (
+ · {missing} not set
+ ) : null}
+
+ );
+}
diff --git a/ui/src/pages/secrets/my-value-state.ts b/ui/src/pages/secrets/my-value-state.ts
new file mode 100644
index 0000000000..4c31cede99
--- /dev/null
+++ b/ui/src/pages/secrets/my-value-state.ts
@@ -0,0 +1,20 @@
+import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
+import { type MyValueState, myValueLabel, myValueTone } from "./user-secret-presentation";
+
+export type { MyValueState };
+export { myValueLabel, myValueTone };
+
+/**
+ * Derive the current user's value state for a definition:
+ * - "set": an active value exists
+ * - "inactive": a value exists but is disabled/archived
+ * - "not_set": no value stored yet
+ */
+export function myValueState(
+ _definition: UserSecretDefinition,
+ secret: CompanySecret | null | undefined,
+): MyValueState {
+ if (!secret) return "not_set";
+ if (secret.status === "active") return "set";
+ return "inactive";
+}
diff --git a/ui/src/pages/secrets/user-secret-presentation.test.ts b/ui/src/pages/secrets/user-secret-presentation.test.ts
new file mode 100644
index 0000000000..eda88a3b1b
--- /dev/null
+++ b/ui/src/pages/secrets/user-secret-presentation.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared";
+import {
+ coverageSummaryLabel,
+ myValueLabel,
+ secretStatusTone,
+} from "./user-secret-presentation";
+import { myValueState } from "./my-value-state";
+
+function makeSecret(status: CompanySecret["status"]): CompanySecret {
+ return {
+ id: "sec-1",
+ companyId: "c1",
+ scope: "user",
+ ownerUserId: "u1",
+ userSecretDefinitionId: "def-1",
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GH token",
+ provider: "local_encrypted",
+ status,
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 1,
+ description: null,
+ lastResolvedAt: null,
+ lastRotatedAt: null,
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "u1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+const definition = { id: "def-1", key: "PERSONAL_GH_TOKEN" } as UserSecretDefinition;
+
+describe("coverageSummaryLabel", () => {
+ it("shows counts only, never values", () => {
+ expect(
+ coverageSummaryLabel({
+ definitionId: "def-1",
+ configuredCount: 5,
+ missingCount: 2,
+ inactiveCount: 0,
+ }),
+ ).toBe("5 of 7 set");
+ });
+
+ it("counts inactive members in the total", () => {
+ expect(
+ coverageSummaryLabel({
+ definitionId: "def-1",
+ configuredCount: 3,
+ missingCount: 1,
+ inactiveCount: 1,
+ }),
+ ).toBe("3 of 5 set");
+ });
+
+ it("renders a dash when coverage is unknown", () => {
+ expect(coverageSummaryLabel(undefined)).toBe("—");
+ });
+});
+
+describe("myValueState", () => {
+ it("is not_set when the user has no value", () => {
+ expect(myValueState(definition, null)).toBe("not_set");
+ expect(myValueLabel(myValueState(definition, null))).toBe("Not set");
+ });
+
+ it("is set when an active value exists", () => {
+ expect(myValueState(definition, makeSecret("active"))).toBe("set");
+ });
+
+ it("is inactive when the value is disabled", () => {
+ expect(myValueState(definition, makeSecret("disabled"))).toBe("inactive");
+ });
+});
+
+describe("secretStatusTone", () => {
+ it("uses emerald for active and muted for disabled", () => {
+ expect(secretStatusTone("active")).toContain("emerald");
+ expect(secretStatusTone("disabled")).toContain("muted");
+ });
+});
diff --git a/ui/src/pages/secrets/user-secret-presentation.tsx b/ui/src/pages/secrets/user-secret-presentation.tsx
new file mode 100644
index 0000000000..3b51daf479
--- /dev/null
+++ b/ui/src/pages/secrets/user-secret-presentation.tsx
@@ -0,0 +1,79 @@
+import type { SecretStatus, UserSecretCoverageSummary } from "@paperclipai/shared";
+import { UserRound } from "lucide-react";
+import { cn } from "../../lib/utils";
+
+/**
+ * User secrets are visually distinct from company secrets via a violet accent
+ * (company secrets use neutral/emerald tones). This keeps the two Secrets tabs
+ * unmistakable at a glance, per the Phase 2 UX direction.
+ */
+export const USER_SECRET_ACCENT_TEXT = "text-violet-700 dark:text-violet-300";
+export const USER_SECRET_ACCENT_BORDER = "border-violet-500/30";
+export const USER_SECRET_ACCENT_BG = "bg-violet-500/10";
+
+/** Small pill used to mark user-scoped rows and headers. */
+export function UserSecretChip({ className, label = "User secret" }: { className?: string; label?: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+/** Tone for a secret/definition status badge. */
+export function secretStatusTone(status: SecretStatus): string {
+ switch (status) {
+ case "active":
+ return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
+ case "disabled":
+ return "border-muted bg-muted text-muted-foreground";
+ case "archived":
+ return "border-border bg-muted/60 text-muted-foreground";
+ default:
+ return "border-border bg-muted text-muted-foreground";
+ }
+}
+
+/** Tone for "my value" state: set (emerald), not set (amber), inactive (muted). */
+export type MyValueState = "set" | "not_set" | "inactive";
+
+export function myValueTone(state: MyValueState): string {
+ switch (state) {
+ case "set":
+ return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
+ case "not_set":
+ return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
+ case "inactive":
+ return "border-muted bg-muted text-muted-foreground";
+ }
+}
+
+export function myValueLabel(state: MyValueState): string {
+ switch (state) {
+ case "set":
+ return "Value set";
+ case "not_set":
+ return "Not set";
+ case "inactive":
+ return "Disabled";
+ }
+}
+
+/**
+ * Coverage is surfaced as counts only, never values, per the UX terminology
+ * decisions. E.g. "5 of 7 members set".
+ */
+export function coverageSummaryLabel(summary: UserSecretCoverageSummary | undefined): string {
+ if (!summary) return "—";
+ const total = summary.configuredCount + summary.missingCount + summary.inactiveCount;
+ return `${summary.configuredCount} of ${total} set`;
+}
diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts
index 3865475d03..0b46439c42 100644
--- a/ui/src/plugins/bridge-init.ts
+++ b/ui/src/plugins/bridge-init.ts
@@ -326,10 +326,10 @@ function PluginSdkAssigneePicker({
companyId,
value,
onChange,
- placeholder = "Assignee",
- noneLabel = "No assignee",
- searchPlaceholder = "Search assignees...",
- emptyMessage = "No assignees found.",
+ placeholder = "Responsible",
+ noneLabel = "No responsible",
+ searchPlaceholder = "Search responsible...",
+ emptyMessage = "No responsible found.",
includeUsers = true,
includeTerminatedAgents = false,
className,
diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts
index 8b13b1cd71..71b4c5ab91 100644
--- a/ui/storybook/fixtures/paperclipData.ts
+++ b/ui/storybook/fixtures/paperclipData.ts
@@ -43,6 +43,7 @@ export const storybookCompanies: Company[] = [
budgetMonthlyCents: 250_000,
spentMonthlyCents: 67_500,
attachmentMaxBytes: 10 * 1024 * 1024,
+ defaultResponsibleUserId: "user-board",
requireBoardApprovalForNewAgents: true,
feedbackDataSharingEnabled: true,
feedbackDataSharingConsentAt: null,
@@ -66,6 +67,7 @@ export const storybookCompanies: Company[] = [
budgetMonthlyCents: 180_000,
spentMonthlyCents: 39_500,
attachmentMaxBytes: 10 * 1024 * 1024,
+ defaultResponsibleUserId: "user-board",
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
@@ -89,6 +91,7 @@ export const storybookCompanies: Company[] = [
budgetMonthlyCents: 90_000,
spentMonthlyCents: 91_200,
attachmentMaxBytes: 10 * 1024 * 1024,
+ defaultResponsibleUserId: "user-board",
requireBoardApprovalForNewAgents: true,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
@@ -712,6 +715,7 @@ export function createIssue(overrides: Partial = {}): Issue {
priority: "high",
assigneeAgentId: "agent-codex",
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: "run-storybook",
executionRunId: "run-storybook",
executionAgentNameKey: "codexcoder",
@@ -1452,6 +1456,9 @@ export const storybookSecrets: CompanySecret[] = [
{
id: "secret-openai",
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "openai_api_key",
name: "OPENAI_API_KEY",
provider: "local_encrypted",
@@ -1473,6 +1480,9 @@ export const storybookSecrets: CompanySecret[] = [
{
id: "secret-aws-prod",
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "prod_aws_deploy",
name: "PROD_AWS_DEPLOY_KEY",
provider: "aws_secrets_manager",
@@ -1494,6 +1504,9 @@ export const storybookSecrets: CompanySecret[] = [
{
id: "secret-github",
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "github_app_pem",
name: "GITHUB_APP_PEM",
provider: "local_encrypted",
@@ -1515,6 +1528,9 @@ export const storybookSecrets: CompanySecret[] = [
{
id: "secret-stripe-archived",
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "stripe_legacy",
name: "STRIPE_LEGACY",
provider: "vault",
@@ -1582,6 +1598,12 @@ export const storybookSecretAccessEvents: SecretAccessEvent[] = [
id: "evt-1",
companyId: "company-storybook",
secretId: "secret-openai",
+ userSecretDefinitionId: null,
+ secretScope: "company",
+ responsibleUserId: null,
+ credentialOwnerUserId: null,
+ credentialSubjectType: null,
+ credentialSubjectId: null,
version: 3,
provider: "local_encrypted",
actorType: "agent",
@@ -1600,6 +1622,12 @@ export const storybookSecretAccessEvents: SecretAccessEvent[] = [
id: "evt-2",
companyId: "company-storybook",
secretId: "secret-openai",
+ userSecretDefinitionId: null,
+ secretScope: "company",
+ responsibleUserId: null,
+ credentialOwnerUserId: null,
+ credentialSubjectType: null,
+ credentialSubjectId: null,
version: 3,
provider: "local_encrypted",
actorType: "system",
@@ -1618,6 +1646,12 @@ export const storybookSecretAccessEvents: SecretAccessEvent[] = [
id: "evt-3",
companyId: "company-storybook",
secretId: "secret-openai",
+ userSecretDefinitionId: null,
+ secretScope: "company",
+ responsibleUserId: null,
+ credentialOwnerUserId: null,
+ credentialSubjectType: null,
+ credentialSubjectId: null,
version: null,
provider: "local_encrypted",
actorType: "agent",
diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agent-management.stories.tsx
index 2fdf2ef24b..90483f5367 100644
--- a/ui/storybook/stories/agent-management.stories.tsx
+++ b/ui/storybook/stories/agent-management.stories.tsx
@@ -254,6 +254,9 @@ const storybookSecrets: CompanySecret[] = [
{
id: "secret-openai",
companyId: COMPANY_ID,
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "openai-api-key",
name: "OPENAI_API_KEY",
provider: "local_encrypted",
@@ -275,6 +278,9 @@ const storybookSecrets: CompanySecret[] = [
{
id: "secret-ops-webhook",
companyId: COMPANY_ID,
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "ops-webhook-token",
name: "OPS_WEBHOOK_TOKEN",
provider: "local_encrypted",
diff --git a/ui/storybook/stories/data-viz-misc.stories.tsx b/ui/storybook/stories/data-viz-misc.stories.tsx
index 9505b5a790..088ff4e717 100644
--- a/ui/storybook/stories/data-viz-misc.stories.tsx
+++ b/ui/storybook/stories/data-viz-misc.stories.tsx
@@ -96,6 +96,7 @@ function makeHeartbeatRun(overrides: Partial): HeartbeatRun {
id: "run-fixture",
companyId,
agentId: "agent-codex",
+ responsibleUserId: null,
invocationSource: "on_demand",
triggerDetail: "manual",
status: "succeeded",
diff --git a/ui/storybook/stories/document-annotations.stories.tsx b/ui/storybook/stories/document-annotations.stories.tsx
index 8b70ded6be..90a4e19f3b 100644
--- a/ui/storybook/stories/document-annotations.stories.tsx
+++ b/ui/storybook/stories/document-annotations.stories.tsx
@@ -238,6 +238,7 @@ function makeIntegratedIssue(): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
+ responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
diff --git a/ui/storybook/stories/environment-variables-editor.stories.tsx b/ui/storybook/stories/environment-variables-editor.stories.tsx
index 62d35faf88..b84afbd180 100644
--- a/ui/storybook/stories/environment-variables-editor.stories.tsx
+++ b/ui/storybook/stories/environment-variables-editor.stories.tsx
@@ -13,6 +13,9 @@ function secret(
return {
id,
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: name.toLowerCase(),
name,
provider: "local_encrypted",
diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx
index fc5f1e983a..7f1a723acd 100644
--- a/ui/storybook/stories/forms-editors.stories.tsx
+++ b/ui/storybook/stories/forms-editors.stories.tsx
@@ -202,6 +202,9 @@ const storybookSecrets: CompanySecret[] = [
{
id: "secret-openai",
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "openai-api-key",
name: "OPENAI_API_KEY",
provider: "local_encrypted",
@@ -223,6 +226,9 @@ const storybookSecrets: CompanySecret[] = [
{
id: "secret-github",
companyId: "company-storybook",
+ scope: "company",
+ ownerUserId: null,
+ userSecretDefinitionId: null,
key: "github-token",
name: "GITHUB_TOKEN",
provider: "local_encrypted",
diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/issue-management.stories.tsx
index 4dfb4d2784..ed021a4c56 100644
--- a/ui/storybook/stories/issue-management.stories.tsx
+++ b/ui/storybook/stories/issue-management.stories.tsx
@@ -53,7 +53,7 @@ import {
const companyId = "company-storybook";
const issueListViewKey = "storybook:issue-management:list";
const scopedIssueListViewKey = `${issueListViewKey}:${companyId}`;
-const visibleColumns: InboxIssueColumn[] = ["status", "id", "assignee", "project", "workspace", "labels", "updated"];
+const visibleColumns: InboxIssueColumn[] = ["status", "id", "assignee", "kickedOffBy", "project", "workspace", "labels", "updated"];
const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary);
const primaryIssue: Issue = {
@@ -123,6 +123,35 @@ const longValueIssue: Issue = {
},
],
};
+const attributionIssues: Issue[] = [
+ {
+ ...primaryIssue,
+ id: "issue-attribution-explicit",
+ title: "Human kickoff with explicit responsible owner",
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ responsibleUserId: "user-product",
+ assigneeAgentId: "agent-codex",
+ },
+ {
+ ...primaryIssue,
+ id: "issue-attribution-collapsed",
+ title: "Responsible auto-derived from kickoff user",
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ responsibleUserId: null,
+ assigneeAgentId: "agent-codex",
+ },
+ {
+ ...primaryIssue,
+ id: "issue-attribution-unassigned",
+ title: "Agent-created task with no responsible human",
+ createdByAgentId: "agent-codex",
+ createdByUserId: null,
+ responsibleUserId: null,
+ assigneeAgentId: "agent-qa",
+ },
+];
function Section({
eyebrow,
@@ -171,6 +200,16 @@ function hydrateStorybookQueries(queryClient: ReturnType)
image: null,
},
},
+ {
+ principalId: "user-product",
+ status: "active",
+ user: {
+ id: "user-product",
+ email: "morgan@paperclip.local",
+ name: "Morgan Product",
+ image: null,
+ },
+ },
],
});
queryClient.setQueryData(
@@ -289,8 +328,9 @@ function ColumnConfigurationMatrix() {
Issue
-
+
Assignee
+ Kicked off by
Project
Workspace
Tags
@@ -317,6 +357,8 @@ function ColumnConfigurationMatrix() {
workspaceName={issue.currentExecutionWorkspace?.name ?? "Board UI"}
assigneeName={issue.assigneeAgentId ? storybookAgentMap.get(issue.assigneeAgentId)?.name ?? null : null}
assigneeUserName={issue.assigneeUserId ? "Riley Board" : null}
+ creatorAgentName={issue.createdByAgentId ? storybookAgentMap.get(issue.createdByAgentId)?.name ?? null : null}
+ creatorUserName={issue.createdByUserId ? "Riley Board" : null}
currentUserId="user-board"
parentIdentifier={storybookIssues.find((candidate) => candidate.id === issue.parentId)?.identifier ?? null}
parentTitle={storybookIssues.find((candidate) => candidate.id === issue.parentId)?.title ?? null}
@@ -746,6 +788,19 @@ function IssueManagementStories() {
/>
+
+ {attributionIssues.map((issue) => (
+
+
{issue.title}
+
undefined}
+ inline
+ />
+
+ ))}
+
diff --git a/ui/storybook/stories/routine-detail-c.stories.tsx b/ui/storybook/stories/routine-detail-c.stories.tsx
index 32ce38ba97..959dd42dc3 100644
--- a/ui/storybook/stories/routine-detail-c.stories.tsx
+++ b/ui/storybook/stories/routine-detail-c.stories.tsx
@@ -78,6 +78,7 @@ const routine: RoutineDetailType = {
projectId: storybookProjects[0]?.id ?? null,
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Send the weekly digest to {{customer_name}}",
description:
"Compile last week's shipped work and email a digest to {{customer_name}} by {{deadline}}.\n\nKeep it to five bullets.",
diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routine-secrets.stories.tsx
index e755659c6d..6aabca5358 100644
--- a/ui/storybook/stories/routine-secrets.stories.tsx
+++ b/ui/storybook/stories/routine-secrets.stories.tsx
@@ -149,6 +149,7 @@ function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 {
projectId: null,
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Nightly digest",
description: "Summarize agent activity each night.",
assigneeAgentId: null,
@@ -170,6 +171,7 @@ function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Ro
projectId: null,
goalId: null,
parentIssueId: null,
+ responsibleUserId: null,
title: "Nightly digest",
description: "Summarize agent activity each night.",
assigneeAgentId: null,
diff --git a/ui/storybook/stories/secrets.stories.tsx b/ui/storybook/stories/secrets.stories.tsx
index f68448e4a9..27fb659239 100644
--- a/ui/storybook/stories/secrets.stories.tsx
+++ b/ui/storybook/stories/secrets.stories.tsx
@@ -2,7 +2,12 @@ import { useEffect, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useQueryClient } from "@tanstack/react-query";
import { AlertCircle, KeyRound } from "lucide-react";
-import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
+import type {
+ CompanySecret,
+ EnvBinding,
+ UserSecretCoverageSummary,
+ UserSecretDefinition,
+} from "@paperclipai/shared";
import { Secrets } from "@/pages/Secrets";
import { SecretBindingPicker, type SecretBindingValue } from "@/components/SecretBindingPicker";
import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor";
@@ -14,6 +19,64 @@ import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"
const COMPANY_ID = "company-storybook";
+const storybookUserSecretDefinitions: UserSecretDefinition[] = [
+ {
+ id: "def-storybook-github",
+ companyId: COMPANY_ID,
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GitHub token",
+ description: "Used when the responsible user's own repos must be reached.",
+ status: "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: "Create a fine-grained PAT with repo read access.",
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ updatedByAgentId: null,
+ updatedByUserId: "user-board",
+ deletedAt: null,
+ createdAt: new Date("2026-06-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-06-02T00:00:00.000Z"),
+ },
+ {
+ id: "def-storybook-openai",
+ companyId: COMPANY_ID,
+ key: "USER_OPENAI_API_KEY",
+ name: "User OpenAI API key",
+ description: "Each member bills agent experiments to their own account.",
+ status: "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: "Create a project-scoped OpenAI key.",
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ updatedByAgentId: null,
+ updatedByUserId: "user-board",
+ deletedAt: null,
+ createdAt: new Date("2026-06-03T00:00:00.000Z"),
+ updatedAt: new Date("2026-06-04T00:00:00.000Z"),
+ },
+];
+
+const storybookUserSecretCoverage: Record = {
+ "def-storybook-github": {
+ definitionId: "def-storybook-github",
+ configuredCount: 5,
+ missingCount: 2,
+ inactiveCount: 0,
+ },
+ "def-storybook-openai": {
+ definitionId: "def-storybook-openai",
+ configuredCount: 7,
+ missingCount: 0,
+ inactiveCount: 0,
+ },
+};
+
// Seed localStorage before CompanyContext mounts so its `useState` initializer reads the right id.
if (typeof window !== "undefined") {
window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID);
@@ -23,6 +86,20 @@ function StorybookSecretsFixtures({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
// Seed query caches synchronously so children hydrate from cache on first render.
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
+ queryClient.setQueryData(
+ queryKeys.secrets.userDefinitions(COMPANY_ID),
+ storybookUserSecretDefinitions,
+ );
+ queryClient.setQueryData(
+ queryKeys.secrets.myUserSecrets(COMPANY_ID),
+ storybookUserSecretDefinitions.map((definition) => ({ definition, secret: null })),
+ );
+ for (const [definitionId, summary] of Object.entries(storybookUserSecretCoverage)) {
+ queryClient.setQueryData(
+ queryKeys.secrets.userDefinitionCoverage(COMPANY_ID, definitionId),
+ summary,
+ );
+ }
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
useEffect(() => {
diff --git a/ui/storybook/stories/user-secrets.stories.tsx b/ui/storybook/stories/user-secrets.stories.tsx
new file mode 100644
index 0000000000..72d95eccb2
--- /dev/null
+++ b/ui/storybook/stories/user-secrets.stories.tsx
@@ -0,0 +1,209 @@
+import { useEffect, type ReactNode } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useQueryClient } from "@tanstack/react-query";
+import type {
+ CompanySecret,
+ EnvBinding,
+ UserSecretCoverageSummary,
+ UserSecretDefinition,
+} from "@paperclipai/shared";
+import { MemoryRouter } from "react-router-dom";
+import { MyUserSecretsTab } from "@/pages/secrets/MyUserSecretsTab";
+import { UserSecretDefinitionsTab } from "@/pages/secrets/UserSecretDefinitionsTab";
+import { MissingUserSecretsBanner } from "@/pages/secrets/MissingUserSecretsBanner";
+import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor";
+import type { MyUserSecretEntry } from "@/api/secrets";
+import { useCompany } from "@/context/CompanyContext";
+import { queryKeys } from "@/lib/queryKeys";
+
+const COMPANY_ID = "company-storybook";
+
+if (typeof window !== "undefined") {
+ window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID);
+}
+
+function makeDefinition(overrides: Partial): UserSecretDefinition {
+ return {
+ id: "def-x",
+ companyId: COMPANY_ID,
+ key: "USER_SECRET",
+ name: "User secret",
+ description: null,
+ status: "active",
+ provider: "local_encrypted",
+ managedMode: "paperclip_managed",
+ providerConfigId: null,
+ providerMetadata: null,
+ usageGuidance: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ updatedByAgentId: null,
+ updatedByUserId: null,
+ deletedAt: null,
+ createdAt: new Date("2026-06-01T00:00:00Z"),
+ updatedAt: new Date("2026-06-01T00:00:00Z"),
+ ...overrides,
+ };
+}
+
+function makeValue(definitionId: string): CompanySecret {
+ return {
+ id: `sec-${definitionId}`,
+ companyId: COMPANY_ID,
+ scope: "user",
+ ownerUserId: "user-me",
+ userSecretDefinitionId: definitionId,
+ key: "USER_SECRET",
+ name: "User secret",
+ provider: "local_encrypted",
+ status: "active",
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 1,
+ description: null,
+ lastResolvedAt: null,
+ lastRotatedAt: null,
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-me",
+ createdAt: new Date("2026-06-02T00:00:00Z"),
+ updatedAt: new Date("2026-06-02T00:00:00Z"),
+ };
+}
+
+const ghToken = makeDefinition({
+ id: "def-gh",
+ key: "PERSONAL_GH_TOKEN",
+ name: "Personal GitHub token",
+ description: "Used when the responsible user's own repos must be reached.",
+ usageGuidance: "Create a fine-grained PAT with repo:read scope.",
+});
+const openai = makeDefinition({
+ id: "def-openai",
+ key: "OPENAI_API_KEY",
+ name: "OpenAI API key",
+ description: "Each member bills to their own OpenAI account.",
+});
+const slack = makeDefinition({
+ id: "def-slack",
+ key: "SLACK_USER_TOKEN",
+ name: "Slack user token",
+ status: "disabled",
+});
+
+const definitions: UserSecretDefinition[] = [ghToken, openai, slack];
+
+const coverage: Record = {
+ "def-gh": { definitionId: "def-gh", configuredCount: 5, missingCount: 2, inactiveCount: 0 },
+ "def-openai": { definitionId: "def-openai", configuredCount: 7, missingCount: 0, inactiveCount: 0 },
+ "def-slack": { definitionId: "def-slack", configuredCount: 1, missingCount: 5, inactiveCount: 1 },
+};
+
+const myEntries: MyUserSecretEntry[] = [
+ { definition: ghToken, secret: null },
+ { definition: openai, secret: makeValue("def-openai") },
+ { definition: slack, secret: null },
+];
+
+function SeedFixtures({ children }: { children: ReactNode }) {
+ const queryClient = useQueryClient();
+ queryClient.setQueryData(queryKeys.secrets.userDefinitions(COMPANY_ID), definitions);
+ queryClient.setQueryData(queryKeys.secrets.myUserSecrets(COMPANY_ID), myEntries);
+ for (const [definitionId, summary] of Object.entries(coverage)) {
+ queryClient.setQueryData(
+ queryKeys.secrets.userDefinitionCoverage(COMPANY_ID, definitionId),
+ summary,
+ );
+ }
+
+ const { selectedCompanyId, setSelectedCompanyId } = useCompany();
+ useEffect(() => {
+ if (selectedCompanyId !== COMPANY_ID) setSelectedCompanyId(COMPANY_ID);
+ }, [selectedCompanyId, setSelectedCompanyId]);
+ if (selectedCompanyId !== COMPANY_ID) return null;
+
+ return {children} ;
+}
+
+function Section({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Product/User secrets",
+ parameters: { layout: "fullscreen", a11y: { test: "off" } },
+};
+export default meta;
+type Story = StoryObj;
+
+export const AdminDefinitions: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const MySecrets: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const MissingWarning: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const EnvPicker: Story = {
+ render: () => {
+ const value: Record = {
+ GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true },
+ OPENAI_API_KEY: { type: "user_secret_ref", key: "OPENAI_API_KEY", required: false },
+ };
+ return (
+
+
+
+ {
+ throw new Error("noop");
+ }}
+ onChange={() => {}}
+ />
+
+
+
+ );
+ },
+};