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
{currentUserId ? (
); })()} + {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 ( +
+
+
+
+ PAP-12462 · P7 +
+

+ Run "on behalf of" surfacing + denial copy +

+

+ Before/after of the two run surfaces and the four denial-related states. +

+
+ + + + + + + + + + + + + + + + + + +
+ + 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 ( + + + ); +} + +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 ( + + + + + + { + 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" /> -
- {secretsQuery.isError ? ( + {secretsQuery.isError || userDefinitionsQuery.isError ? (
Failed to load secrets:{" "} - {(secretsQuery.error as Error).message} -
- ) : secrets.length === 0 && !secretsQuery.isPending ? ( + ) : unifiedRows.length === 0 && !secretsQuery.isPending && !userDefinitionsQuery.isPending ? ( setCreateOpen(true)} + onAction={openCreateSecret} /> - ) : filtered.length === 0 ? ( + ) : filteredRows.length === 0 ? ( ) : ( - - - - - - - - - - - - - - - - - {filtered.map((secret) => ( - setSelectedSecretId(secret.id)} +
+
+
-
- - - - - - - - - - - ))} - -
NameModeProviderStatusVersionLast rotatedLast resolvedReferencesReference
-
{secret.name}
-
- {modeLabel(secret.managedMode)} - -
{providerLabel(providers, secret.provider)}
-
- - {secret.status} - - v{secret.latestVersion} - {formatRelative(secret.lastRotatedAt)} - - {formatRelative(secret.lastResolvedAt)} - - - - {secret.managedMode === "external_reference" ? ( - - - {secret.externalRef ?? "—"} - - ) : ( - Owned - )} - - -
+
+
+
+ {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} + + +
+
+ + Company + + {modeLabel(selectedSecret.managedMode)} + {providerLabel(providers, selectedSecret.provider)} + v{selectedSecret.latestVersion} +
-
+
- {selectedSecret.status === "active" ? ( - - ) : ( - - )} - {selectedSecret.status === "archived" ? ( - - ) : ( - - )} - + + + + + + + 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} + + +
+
+ + + + +
+
+
+ + + + + + + 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 - -
-
+
- setCreateForm((current) => ({ ...current, name: event.target.value })) - } - placeholder="OPENAI_API_KEY" + onChange={(event) => { + const name = event.target.value; + setCreateForm((current) => ({ + ...current, + name, + key: createKeyDirty + ? current.key + : secretValueProvider === "user" + ? normalizeUserSecretKeyForPreview(name) + : normalizeSecretKeyForPreview(name), + })); + }} + placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "OPENAI_API_KEY"} autoFocus />
- setCreateForm((current) => ({ ...current, key: event.target.value })) - } - placeholder="auto from name" + onChange={(event) => { + setCreateKeyDirty(true); + setCreateForm((current) => ({ ...current, key: event.target.value })); + }} + placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"} + disabled={Boolean(editingDefinition)} + className={secretValueProvider === "user" ? "font-mono text-sm" : undefined} /> +

+ {secretValueProvider === "user" + ? editingDefinition + ? "Stable env binding key. Cannot be changed." + : "Env-style key used by user-secret bindings." + : "Shared secret keys keep lowercase dash normalization."} +

- - - 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) => ( - - ))} - - {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} -
-
- - - {selectedCreateProviderConfig ? ( - - ) : ( -

- Existing deployment-level provider settings stay available for backwards compatibility. -

- )} -
- {createMode === "managed" ? ( +
+ ) : null} + + {secretValueProvider === "company" ? ( <> + setCreateMode(value as CreateMode)}> + + + Managed value + + + External reference + + + +
+ + + {createProviderBlockReason ? ( +

+ + {createProviderBlockReason} +

+ ) : createProviderHealthText ? ( +

{createProviderHealthText}

+ ) : null} +
+
+ + + {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" />
+ + ) : ( +
+ + + 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. +

+
+ )} ) : ( -
- - - 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. +
+
+ +