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:
Dotta 2026-07-05 05:58:20 -05:00 committed by GitHub
parent eb2cb916be
commit ad961227f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
211 changed files with 12806 additions and 701 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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 });
}
});
});

View File

@ -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 };
}

View File

@ -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). ` +

View File

@ -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";

View File

@ -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");

View File

@ -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;

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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 }),

View File

@ -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),

View File

@ -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
)`,
),
}),
);

View File

@ -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,

View File

@ -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";

View File

@ -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),

View File

@ -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),

View File

@ -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),
}),

View File

@ -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`),
}),
);

View File

@ -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`),
}),
);

View File

@ -573,6 +573,7 @@ function paperclipIssue(overrides: Partial<Issue> = {}): Issue {
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,

View File

@ -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,

View File

@ -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`,

View File

@ -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;

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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");
}
});
});

View File

@ -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.`,
};
}

View File

@ -12,6 +12,7 @@ export interface Company {
budgetMonthlyCents: number;
spentMonthlyCents: number;
attachmentMaxBytes: number;
defaultResponsibleUserId: string | null;
requireBoardApprovalForNewAgents: boolean;
feedbackDataSharingEnabled: boolean;
feedbackDataSharingConsentAt: Date | null;

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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>;

View File

@ -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,

View File

@ -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({

View File

@ -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(),

View File

@ -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({

View File

@ -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({

View File

@ -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();

View File

@ -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",
});

View File

@ -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` },
});
});
});

View File

@ -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");

View File

@ -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", () => {

View File

@ -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",
});
});
});

View File

@ -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();

View File

@ -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(),

View File

@ -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([

View File

@ -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`,

View File

@ -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,

View File

@ -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,

View File

@ -97,6 +97,7 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({

View File

@ -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: {

View File

@ -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({

View File

@ -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";

View File

@ -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);
});
});

View File

@ -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({

View File

@ -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({

View File

@ -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({

View File

@ -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(),
});

View File

@ -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());

View File

@ -160,6 +160,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({

View File

@ -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);

View File

@ -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();

View File

@ -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,

View File

@ -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!;
}

View File

@ -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,

View File

@ -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).

View File

@ -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({

View File

@ -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({

View File

@ -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",

View File

@ -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);

View File

@ -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();

View File

@ -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 } : {}),

View File

@ -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;
};

View File

@ -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") {

View File

@ -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",
};

View File

@ -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,

View File

@ -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;

View File

@ -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({

View File

@ -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);

View File

@ -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)) {

View File

@ -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,

View File

@ -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,

View File

@ -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,
});

View File

@ -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({

View File

@ -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,
});
}

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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