feat(secrets): add user-specific runtime secrets (#8825)
## 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 <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
eb2cb916be
commit
ad961227f5
|
|
@ -23,6 +23,7 @@ function makeCompany(overrides: Partial<Company>): Company {
|
|||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
defaultResponsibleUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ function secret(partial: Partial<CompanySecret>): 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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<secret key>`
|
||||
- `paperclip:environment=<environment tag>`
|
||||
|
||||
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/<environment>/<company-id>/user-secrets/<definition-key>/<opaque-owner-id>
|
||||
```
|
||||
|
||||
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/<deployment-id>/*`; 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CodexCredentialReadiness> {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AdapterExec
|
|||
// with OPENAI_API_KEY="" the provider rejects every request with
|
||||
// "401 Missing bearer"; fail fast with a clear adapter error instead of
|
||||
// emitting unauthenticated calls. External overrides manage their own auth.
|
||||
const effectiveHomeIsManaged = configuredCodexHome == null || configuredHomeIsManaged;
|
||||
if (
|
||||
effectiveHomeIsManaged &&
|
||||
!configuredOpenAiApiKey &&
|
||||
!(await codexHomeHasUsableAuth(effectiveCodexHome))
|
||||
) {
|
||||
// This is the execute-time backstop for the control plane's pre-dispatch
|
||||
// configuration-incomplete gate (see server heartbeat) — both decide
|
||||
// readiness through the same `evaluateCodexCredentialReadiness` predicate, so
|
||||
// they cannot drift.
|
||||
const credentialReadiness = await evaluateCodexCredentialReadiness({
|
||||
env: process.env,
|
||||
companyId: agent.companyId,
|
||||
configuredCodexHome,
|
||||
configuredApiKey: configuredOpenAiApiKey,
|
||||
});
|
||||
if (credentialReadiness.managed && !credentialReadiness.ready) {
|
||||
throw new Error(
|
||||
`no Codex credentials provisioned for managed home "${effectiveCodexHome}" ` +
|
||||
`(no usable auth.json and OPENAI_API_KEY is empty). ` +
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ export { execute, ensureCodexSkillsInjected } from "./execute.js";
|
|||
export {
|
||||
reconcileManagedCodexHome,
|
||||
isManagedCodexHomePath,
|
||||
evaluateCodexCredentialReadiness,
|
||||
type ReconcileManagedCodexHomeInput,
|
||||
type ReconcileManagedCodexHomeResult,
|
||||
type ReconcileManagedCodexHomeStatus,
|
||||
type CodexCredentialReadiness,
|
||||
type CodexCredentialReadinessInput,
|
||||
type CodexCredentialAuthMode,
|
||||
} from "./codex-home.js";
|
||||
export { listCodexSkills, syncCodexSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
CREATE TABLE IF NOT EXISTS "user_secret_definitions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"provider" text DEFAULT 'local_encrypted' NOT NULL,
|
||||
"managed_mode" text DEFAULT 'paperclip_managed' NOT NULL,
|
||||
"provider_config_id" uuid,
|
||||
"provider_metadata" jsonb,
|
||||
"usage_guidance" text,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"updated_by_agent_id" uuid,
|
||||
"updated_by_user_id" text,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> 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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AgentApiKeyScope | null>(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>(),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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<Record<string, unknown>>(),
|
||||
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`),
|
||||
}),
|
||||
);
|
||||
|
|
@ -573,6 +573,7 @@ function paperclipIssue(overrides: Partial<Issue> = {}): Issue {
|
|||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
responsibleUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Issue, "responsibleUserId" | "createdByUserId">,
|
||||
): 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 <agent>" 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<Issue, "createdByUserId" | "createdByAgentId" | "responsibleUserId">,
|
||||
): 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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.`,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export interface Company {
|
|||
budgetMonthlyCents: number;
|
||||
spentMonthlyCents: number;
|
||||
attachmentMaxBytes: number;
|
||||
defaultResponsibleUserId: string | null;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: Date | null;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, EnvBinding>;
|
||||
|
||||
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<string, unknown> | 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;
|
||||
|
|
|
|||
|
|
@ -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<typeof createCompanySchema>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<typeof createIssueLabelSchema>;
|
||||
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<typeof createSecretSchema>;
|
||||
|
||||
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<typeof rotateSecretSchema>;
|
||||
|
||||
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<typeof createSecretBindingSchema>;
|
||||
|
||||
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<typeof createUserSecretDefinitionSchema>;
|
||||
|
||||
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<typeof updateUserSecretDefinitionSchema>;
|
||||
|
||||
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<typeof createUserSecretValueSchema>;
|
||||
|
||||
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<typeof updateUserSecretValueSchema>;
|
||||
|
||||
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<typeof rotateUserSecretValueSchema>;
|
||||
|
||||
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<typeof createUserSecretDeclarationSchema>;
|
||||
|
||||
const safeShortText = z.string().trim().min(1).max(160);
|
||||
const optionalSafeShortText = safeShortText.optional().nullable();
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> = [];
|
||||
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<string, unknown>) {
|
||||
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` },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof createDb>,
|
||||
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<typeof createDb>,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
scope: Record<string, unknown> | 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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TestGraph> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => {
|
|||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string>(),
|
||||
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<string>(),
|
||||
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<string>(),
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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<typeof import("../adapters/index.ts")>("../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<typeof createDb>, 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<typeof createDb>;
|
||||
let heartbeat!: ReturnType<typeof heartbeatService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
|||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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<BetterAuthSessionResult | null>;
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<string, unknown>
|
||||
: null;
|
||||
recordResponsibleUserDenialFromHttpError(req, details);
|
||||
if (err.status >= 500) {
|
||||
attachErrorContext(
|
||||
req,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): Record<string,
|
|||
redacted[key] = sanitizeValue(value);
|
||||
continue;
|
||||
}
|
||||
if (isUserSecretRefBinding(value)) {
|
||||
redacted[key] = sanitizeValue(value);
|
||||
continue;
|
||||
}
|
||||
if (isPlainBinding(value)) {
|
||||
redacted[key] = { type: "plain", value: REDACTED_EVENT_VALUE };
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -4366,7 +4366,9 @@ export function accessRoutes(
|
|||
|
||||
const created = await agents.createApiKey(
|
||||
joinRequest.createdAgentId,
|
||||
"initial-join-key"
|
||||
"initial-join-key",
|
||||
{ kind: "standard" },
|
||||
{ responsibleUserId: joinRequest.approvedByUserId ?? joinRequest.requestingUserId ?? null },
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
@ -4378,8 +4380,9 @@ export function accessRoutes(
|
|||
entityId: created.id,
|
||||
details: {
|
||||
agentId: joinRequest.createdAgentId,
|
||||
joinRequestId: requestId
|
||||
}
|
||||
joinRequestId: requestId,
|
||||
responsibleUserId: created.responsibleUserId,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import type {
|
|||
} from "@paperclipai/adapter-utils";
|
||||
import { skillVersionSelectionMap } from "../services/runtime-skill-selections.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import { authorizationDeniedDetails } from "../services/authorization.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findActiveServerAdapter,
|
||||
|
|
@ -666,7 +667,7 @@ export function agentRoutes(
|
|||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
throw forbidden(decision.explanation);
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
if (req.actor.type !== "agent") return null;
|
||||
const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
|
||||
|
|
@ -685,7 +686,7 @@ export function agentRoutes(
|
|||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
throw forbidden(decision.explanation);
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanReadConfigurations(req: Request, companyId: string) {
|
||||
|
|
@ -833,7 +834,7 @@ export function agentRoutes(
|
|||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
throw forbidden(decision.explanation);
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
|
||||
|
|
@ -3209,7 +3210,9 @@ export function agentRoutes(
|
|||
if (!agent) {
|
||||
return;
|
||||
}
|
||||
const key = await svc.createApiKey(id, req.body.name, req.body.scope);
|
||||
const key = await svc.createApiKey(id, req.body.name, req.body.scope, {
|
||||
responsibleUserId: req.actor.userId ?? null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
|
|
@ -3218,7 +3221,12 @@ export function agentRoutes(
|
|||
action: "agent.key_created",
|
||||
entityType: "agent",
|
||||
entityId: agent.id,
|
||||
details: { keyId: key.id, name: key.name, scope: key.scope },
|
||||
details: {
|
||||
keyId: key.id,
|
||||
name: key.name,
|
||||
scope: key.scope,
|
||||
responsibleUserId: key.responsibleUserId,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(key);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,26 @@
|
|||
import type { Request } from "express";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { responsibleUserAuthzShadowMode } from "../services/authorization.js";
|
||||
|
||||
function throwOrShadowResponsibleUserCompanyAccessDeny(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
code: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE",
|
||||
message: string,
|
||||
) {
|
||||
logger.warn({
|
||||
authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce",
|
||||
code,
|
||||
action: "company_access",
|
||||
companyId,
|
||||
actorAgentId: req.actor.agentId ?? null,
|
||||
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
|
||||
method: req.method,
|
||||
}, "responsible-user company access intersection denied");
|
||||
if (responsibleUserAuthzShadowMode()) return;
|
||||
throw new HttpError(403, message, { code });
|
||||
}
|
||||
|
||||
export function assertAuthenticated(req: Request) {
|
||||
if (req.actor.type === "none") {
|
||||
|
|
@ -55,6 +76,30 @@ export function assertCompanyAccess(req: Request, companyId: string) {
|
|||
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
if (req.actor.type === "agent" && req.actor.onBehalfOfUserId?.trim()) {
|
||||
const membership = req.actor.onBehalfOfMemberships?.find(
|
||||
(item) => 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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
|||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, 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<T extends object>(
|
||||
db: Db,
|
||||
req: Request,
|
||||
res: Response,
|
||||
companyId: string,
|
||||
input: T,
|
||||
options: { surface: string; entityId?: string | null },
|
||||
) {
|
||||
const sanitized = { ...input } as T & Record<string, unknown>;
|
||||
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<typeof req.body.children[number]>) {
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<typeof pipelineServic
|
|||
});
|
||||
if (!decision.allowed) {
|
||||
throw new HttpError(403, decision.explanation, {
|
||||
code: "pipeline_write_forbidden",
|
||||
code: decision.code ?? "pipeline_write_forbidden",
|
||||
reason: decision.reason,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,57 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
createSecretProviderConfigSchema,
|
||||
createSecretSchema,
|
||||
createUserSecretDefinitionSchema,
|
||||
createUserSecretValueSchema,
|
||||
remoteSecretImportPreviewSchema,
|
||||
remoteSecretImportSchema,
|
||||
rotateSecretSchema,
|
||||
rotateUserSecretValueSchema,
|
||||
secretProviderConfigDiscoveryPreviewSchema,
|
||||
updateSecretProviderConfigSchema,
|
||||
updateSecretSchema,
|
||||
updateUserSecretDefinitionSchema,
|
||||
updateUserSecretValueSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
import { logActivity, secretService } from "../services/index.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
|
||||
function assertSecretDefinitionAdmin(req: Parameters<typeof assertBoard>[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<typeof assertBoard>[0]) {
|
||||
assertBoard(req);
|
||||
if (req.actor.userId) return req.actor.userId;
|
||||
throw unauthorized("User identity required for user-specific secrets");
|
||||
}
|
||||
|
||||
function boardActorUser(req: Parameters<typeof assertBoard>[0]) {
|
||||
assertBoard(req);
|
||||
return { userId: req.actor.userId ?? null, agentId: null };
|
||||
}
|
||||
|
||||
function userSecretDefinitionActivityActor(req: Parameters<typeof assertBoard>[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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@ interface AgentSecretBindingSyncService {
|
|||
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
|
||||
envValue: unknown,
|
||||
) => Promise<unknown>;
|
||||
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<unknown>;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<AuthorizationDecision, "allowed">): 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<string, Promise<ResponsibleUserSnapshot>>;
|
||||
};
|
||||
|
||||
const responsibleUserSnapshotCache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; promise: Promise<ResponsibleUserSnapshot> }
|
||||
>();
|
||||
|
||||
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<boolean> {
|
||||
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<ResponsibleUserSnapshot> {
|
||||
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<ResponsibleUserSnapshot> {
|
||||
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<string, unknown> | null;
|
||||
},
|
||||
agentDecision: AuthorizationDecision,
|
||||
): Promise<AuthorizationDecision> {
|
||||
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<string, unknown> | null;
|
||||
}): Promise<AuthorizationDecision> {
|
||||
const agentDecision = await decideBase(input);
|
||||
return applyResponsibleUserIntersection(input, agentDecision);
|
||||
}
|
||||
|
||||
return {
|
||||
decide,
|
||||
decidePrincipalGrant,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof getIssueExecutionContext>> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null;
|
||||
routineEnvContext: Awaited<ReturnType<typeof getRoutineEnvForExecutionIssue>>;
|
||||
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<string, unknown>;
|
||||
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null;
|
||||
routineEnvContext: Awaited<ReturnType<typeof getRoutineEnvForExecutionIssue>>;
|
||||
}) {
|
||||
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<string, unknown>,
|
||||
) {
|
||||
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<string> | 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,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue