diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md
index 48086411897..ae2989429ac 100644
--- a/doc/PRODUCT.md
+++ b/doc/PRODUCT.md
@@ -391,3 +391,11 @@ operation logs retain their app authorization. Workspace `_issuePrivacySources`
operation privacy metadata protect app-record disclosure; they do not grant or deny
filesystem execution or task-bound file browsing. Current trust and sandbox policy,
publication receipts, binding checks, and lifecycle locks remain authoritative.
+
+## Agent connection administration (2026-10-13)
+
+Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
+
+The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
+
+The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 948d3fd20c2..ba967dddaa4 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -2612,3 +2612,11 @@ Company-managed [Prompt Injection Prevention](prompt-injection-prevention.md)
optionally classifies existing tool-result boundaries through a shared OpenRouter
connection. Settings → Connections is permanent; valid model verdicts take
precedence and dependency failures use the original built-in detector.
+
+## Agent connection administration (2026-10-13)
+
+Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
+
+The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
+
+The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
diff --git a/doc/SPEC.md b/doc/SPEC.md
index 556d3d70717..6166b7b57b7 100644
--- a/doc/SPEC.md
+++ b/doc/SPEC.md
@@ -864,3 +864,11 @@ Company-managed [Prompt Injection Prevention](prompt-injection-prevention.md)
optionally classifies existing tool-result boundaries through a shared OpenRouter
connection. Settings → Connections is permanent; valid model verdicts take
precedence and dependency failures use the original built-in detector.
+
+## Agent connection administration (2026-10-13)
+
+Standard agents can configure connections through the same domain APIs used by the app, bounded by their grants and their responsible user's current authority. Fresh standard agents for default Operators can manage app/tool connections, chat/email channels, AI accounts and pools, profiles and gateways without additional grants. Existing grants are not backfilled; explicit restrictions persist. Gateway administration retains the responsible human's administrative permission and does not grant agents blanket `tools:admin`.
+
+The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
+
+The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
diff --git a/doc/agent-permission-defaults.md b/doc/agent-permission-defaults.md
index 54113802773..6500ddb9215 100644
--- a/doc/agent-permission-defaults.md
+++ b/doc/agent-permission-defaults.md
@@ -29,3 +29,11 @@ Agent-authenticated callers cannot set process adapter configuration or switch a
The new direct grants are withheld from low trust agents and managed built-in agents. Low trust run or project policies can also deny privileged actions even if an agent has a grant. There is no permission backfill or upgrade migration for existing agents. A standard pending new hire receives the new default set when approved and activated. Invitation approval retains the set when it replaces a new agent's grants and preserves any explicitly scoped default grant. Existing scoped grants stay unchanged. A grant removed by an operator stays removed; ordinary updates and startup do not reapply it.
The standard agent configuration default supports the agent setup in the linked runner task. Enabling a warm runtime can still depend on the target agent's adapter and runtime configuration, provider availability, and any responsible-user or approval checks.
+
+## Connection administration (2026-10-13)
+
+The five default tool grants are usable through the existing connection APIs and default native `search_api`/`call_api`. A fresh standard agent for a normally provisioned Operator can configure app/tool, chat/email and AI connections, profiles and gateways without additional agent grants. Gateway management uses the agent's `tools:manage_connections` grant and still requires `tools:admin` on the responsible human. It does not grant blanket agent administration.
+
+Connection management always intersects the current human's authority, including in responsible-user shadow mode. Personal self-service remains available where the human setup surface permits it; sharing and assignments require authority over their targets. Current run/work-mode/trust restrictions, scoped grants and explicit grant removal remain effective. No existing-agent permissions are backfilled. Agent-issued gateway clients preserve this delegation at every use, even after the setup run ends.
+
+See [the authorization and qualification record](plans/2026-10-13-connection-api-parity.md) and the operation-reviewed [agent access maps](../maps/agent-access-map/connection-setup.md). Human consent, credential entry and identity confirmation remain bounded handoffs to saved setup.
diff --git a/doc/plans/2026-10-13-connection-api-parity.md b/doc/plans/2026-10-13-connection-api-parity.md
new file mode 100644
index 00000000000..a578b1a80b0
--- /dev/null
+++ b/doc/plans/2026-10-13-connection-api-parity.md
@@ -0,0 +1,107 @@
+# Agent API parity for connection configuration
+
+Dated 2026-10-13. Implementation and qualification record for the connection-management change based on `e43af9770a36ff92e14cf0e31ee16fa238310f76`.
+
+## Outcome
+
+A newly created standard agent can administer connections using its existing default grants and its responsible user's authority. The default Operator has the human permissions needed for these operations. API reachability includes app/tool connections, chat/email endpoints, GitHub and Slack setup, AI accounts and pools, profiles, named gateways and gateway clients. Human authentication and trusted-host operations retain their explicit boundaries.
+
+No agent grant backfill is performed. An existing missing or scoped grant remains missing or scoped. An operator can explicitly change those grants through the existing permission-management surface. Agents do not acquire `tools:admin` by default and cannot edit their own principal permissions or approve their own governed requests.
+
+## Authorization contract
+
+`server/src/services/connection-management.ts` resolves authenticated management context and invokes the existing authorization evaluator. `server/src/routes/connection-management-authz.ts` resolves route resources and all referenced targets, then supplies that context to services. Capability reporting calls the same evaluator; every mutation checks authority again.
+
+| Operation | Agent grant | Responsible-user ceiling |
+| --- | --- | --- |
+| Configure connections, channels and AI accounts | `tools:manage_connections` | Existing personal/creator authority or connection management |
+| Profiles, assignments and action policies | `tools:manage_profiles` | Corresponding profile and target-agent authority; changes to connection audience also require connection management |
+| Named gateways and client tokens | `tools:manage_connections` | `tools:admin`; bindings also require profile and target authority |
+| Audit | `tools:view_audit` | Audit and resource visibility |
+| Start/stop runtimes | `tools:manage_runtime` | Runtime and resource authority |
+| Test actions | `tools:use` | Credential access, target-agent authority and ordinary tool/action policy |
+
+The acting principal, responsible user, credential owner and target agent remain distinct. Personal credentials belong to the responsible human. Activity records identify the actual agent, responsible user, run or API key, resource and authorization reason. API parameters cannot select a responsible user or forge ownership attribution.
+
+Agent keys resolve their saved responsible user. Run JWTs require current run identity and assignment. Both fail closed on absent user context, inactive membership, foreign companies, unavailable personal credentials, invalid scopes or low trust. Connection authorization enforces the human ceiling even when general responsible-user authorization runs in shadow mode. `skill_test` and `task_bridge` cannot administer connections. Ask and Plan contexts cannot mutate configuration. Connection/profile/gateway/provider/purpose/operation grant scopes apply to resolved resources, including plural policy selectors and affected targets.
+
+Services recheck management context after asynchronous provider work before sensitive persistence. Existing revisions, transaction locks, credential leases, vault storage, duplicate prevention and cleanup stay authoritative. Reads filter private accounts, referenced profile/pool resources and task activity. Configuration authority is separate from use: an allowed policy change does not bypass Off/Ask first/Allowed execution policy.
+
+Binding and audience authorization checks field presence, including `null`, `false` and empty arrays. Clearing an assignment or reducing an audience remains a profile-management operation after an explicit grant revocation. Gateway binding changes check both the saved and replacement profile/project/task contexts; reauthorization reloads the saved side after asynchronous work. Referenced resources require each applicable connection and profile permission, including the replacement side of a project binding.
+
+## Saved authentication and delegation
+
+Agent setup responses retain their browser fields and add `requiredUserAction` where a human step is needed. It identifies the addressed user, saved resource, setup destination, reason and continuation. Chat handoffs reuse the saved endpoint and existing connection-intent delivery. AI login handoffs reopen the saved sign-in intent in the existing credential UI. Completion is based on server state; the UI does not ask a human to certify setup success.
+
+Generic app OAuth returns the saved setup destination without creating agent-bound provider state; the human browser starts the existing session-bound authorization flow. Its `expiresAt` is null until that human step creates an expiring provider session. Slack/GitHub provider callbacks preserve human subject and state checks. Agent-created GitHub setup binds its browser session when the addressed human opens it. Identity callbacks validate the current human and session before consuming state, and revalidate authority before saving identity. Wrong-user callbacks leave the rightful user's flow intact. Continuation rechecks task state and current authority after a yielding run; cancelled tasks and revoked access cannot be revived by a late provider result.
+
+Agent-issued durable gateway clients store `responsible_user_id` alongside `created_by_agent_id`. Token use reevaluates issuer/user authority, gateway/profile scope, target constraints, credential access and action restrictions. The responsible-user marker remains after deletion clears the issuer foreign key, so an orphaned delegated token fails closed instead of becoming a system client. It does not inherit the lifetime of the setup run. Heartbeat tokens remain bound to their authenticated run. Legacy agent-issued clients without a responsible user fail closed; existing board-issued clients retain their behavior. Migration `0378_slim_nehzno.sql` is additive and does not infer legacy identity or modify agent grants.
+
+## Discovery and guidance
+
+OpenAPI carries explicit REST transport and connection-management authorization metadata. JSON authorization initiation and status are callable through default `search_api`/`call_api`; OAuth callbacks, redirects, MCP and WebSocket sessions retain their protocols. There is no new administration tool family.
+
+The shared connection guidance is delivered by the runtime prompt, search tools and existing adapter/CLI consumers. It tells agents to distinguish tool/channel/AI purposes, configure what the user requested when authorized, suggest relevant missing connections, reuse saved drafts and hand off only missing decisions, credentials, consent or authority. It prohibits silently relaxing policy after failed execution and distinguishes saved, authenticated, usable and verified states. Native API setup secrets and gateway credentials use protected replay receipts and redacted history.
+
+## Operation inventory
+
+The existing maps are the checklist, not a second runtime registry:
+
+| Inventory | Coverage |
+| --- | --- |
+| [Connection setup](../../maps/agent-access-map/connection-setup.md) | Catalog/method discovery, preflight, drafts, authentication, connection intents and host boundaries |
+| [App permissions](../../maps/agent-access-map/app-permissions.md) | Account lifecycle, installations/grants, policy, catalog, action tests and activity |
+| [Chat channels](../../maps/agent-access-map/chat-channels.md) | Endpoint/provider setup, settings, defaults, destination access, GitHub/Slack registration and identity, email, tests and lifecycle |
+| [Gateways and profiles](../../maps/agent-access-map/gateways-profiles.md) | Profiles/bindings, gateway configuration, client tokens, runtime controls, protocol and audit |
+| [Model accounts](../../maps/agent-access-map/runs-adapters.md#model-accounts) | AI discovery, key/login setup, reconnect/defaults, managed agent bindings, pools and lifecycle |
+
+The route inventory was reconciled against the production `tools`, `chatEndpoints`, `email`, `ai-connections`, `ai-connection-pools` and relevant `agents` UI API clients. Human-only operations remain individually classified: browser callback/identity confirmation and credential entry, principal approvals, arbitrary local configuration import/host-template authoring, cloud instance enrollment, and manual chat publication/replay that does not run through the governed agent tool path. Approved template use and ordinary authorized remote configuration remain delegable. Catalog/provider availability gates still apply.
+
+## Qualification
+
+`server/src/__tests__/connection-management.integration.test.ts` runs the assembled server, production bearer authentication, real database/vault persistence and deterministic provider responses. It creates each standard agent with an actual human-authenticated `POST /companies/:companyId/agents`; it provisions the human using normal Operator role defaults. It adds no special agent grants and does not inject `req.actor`. Only fixture sign-in throttling is disabled. GitHub/Slack/OpenAI/AgentMail responses are fixtures, not live provider proof.
+
+The suite exercises:
+
+- Fresh default app connections, profiles, chat settings, AI account/binding/pool and named gateway/token creation; no default `tools:admin` grant.
+- Actual persistent API keys and run JWTs, native discovery/calls, and delivery of the new built-in guidance.
+- Correct human ownership and actual-agent audit attribution, including personal grant delegation and revocation; persisted settings and optimistic revision conflicts.
+- Slack configured credentials and test messages under ordinary action policy; repeated request IDs do not resend.
+- GitHub app import, repository/verification configuration and real-session identity handoff; wrong-user and replay rejection.
+- Saved chat handoff reuse and existing task continuation; late credential promotion after yield, cancellation and user revocation.
+- User restrictions with legitimate personal self-service, missing/scoped agent grants, foreign companies/accounts, forged identity, invalid responsible users, bridge keys, low trust and work-mode restrictions.
+- Durable gateway use after the setup run finishes, purpose-scoped grants, denial after membership or issuer-grant revocation and in the persisted cleared-issuer state, encrypted native token receipts, redacted history and retry without a second token.
+
+Focused neighboring tests cover existing human routes, callback behavior, provider setup, device/setup-token sign-in, native API policy, secret redaction, saved setup UI and connection-intent behavior. Maps distinguish individually executed fixture operations from source-reviewed operations. Scripted evidence proves reachability and behavior, not an empirical LLM success rate.
+
+Required final checks are `node scripts/check-maps.mjs`, `pnpm -r typecheck`, `pnpm test:run`, `pnpm build`, database migration validation and UI token gates. The final handoff records their actual results. Live provider setup, a new chatbot skill and redesigned setup workflows are outside this delivery.
+
+## Historical validation record (2026-10-13)
+
+- Focused connection, AI hiring, native API and OpenAPI suites: **2,533 passed**, including **16 assembled-server acceptance scenarios** using production authentication and default agent creation.
+- Final fresh-process connection qualification: **618 passed across five suites**, including **all 18 assembled-server acceptance scenarios**, 418 tool-access tests, 87 gateway acceptance tests, 73 authorization tests and 22 decision-model tests. This covers binding/audience revocation, both sides of profile/project scope changes, current membership authority and gateway-token ownership metadata.
+- Chat compatibility regression selection: **49 passed** (Slack tests, resource auditing, manager boundaries and Teams activity/projections).
+- Saved setup UI, canonical guidance and native history redaction: passed in the neighboring focused run; its three outdated API transport/security assertions were corrected and included in the 2,533-test passing run.
+- `pnpm -r typecheck`: passed.
+- `pnpm build`: passed.
+- Database migration numbering/safety validation: passed through typecheck/build; existing historical safety findings remain covered by the repository baseline.
+- `node scripts/check-maps.mjs`: passed, 35 areas / 176 capability IDs; structural consistency only.
+- UI token gates: passed.
+- Broad UI run: 8,210 passed / 5 failed under contention; both failing suites passed serially (82 tests).
+- Shared contracts and catalog: 941 passed. Adapter utilities: 1,740 passed / 5 failed under contention; all five failing suites passed serially (217 tests).
+- Remaining source-only adapter/plugin projects: 2,087 passed / 1 macOS temporary-path assertion failed; that suite passed with the repository runner’s canonical temporary directory (14 tests).
+- Serialized route/auth suites without embedded PostgreSQL: 1,849 passed / 22 failures in two empty-database fixtures; after adding the resource/membership fixture rows, both suites passed (25 tests). Their authentication, isolation and provider-error assertions remain exercised.
+- Email address/provider regression suite: 10 passed after supplying resource and membership rows to its old empty-database fixture. HTTP log redaction: 66 passed in a fresh process after a sweep timeout.
+- CLI suites without embedded PostgreSQL: 462 passed across 63 files. The three database-backed suites produced 62 passes and three embedded-PostgreSQL initialization failures in `worktree.test.ts`. The host was at its 32/32 shared-memory segment limit; these worktree tests need additional concurrent clusters. Other chats' databases and system limits were not modified.
+- Database package: 194 passed across 50 files.
+- Database-backed serialized routes: 1,208 passed / two failed / 73 skipped. Seven suites hit their 20-second database startup limit; the failures were an outdated AI-pool role fixture and a routine test timeout. All affected suites passed fresh-process reruns.
+- Final isolated gateway/service/route reruns: **320 passed across 13 suites**, including the startup failures, skill cleanup deadlock, AI-pool membership fixture and routine timeout. The stale-lock route suite initially skipped its 14 tests after a failed database availability probe; a final isolated retry executed and passed all 14.
+- Final assembled-server acceptance after the gateway scope and orphaned-token fixes: **18 passed**. The orphaned-token case models the token foreign key's persisted `ON DELETE SET NULL` state without deleting protected historical audit references; token use still traverses the real public gateway endpoint.
+- Final workspace typecheck/build, migration checks, maps, token gates and `git diff --check`: passed. The generated migration retains exactly the five newest schema snapshots.
+- `pnpm test:run`: the general server sweep finished with 19,282 passed / 15 failed / 381 skipped across 914 files (898 passed, 10 failed, 6 skipped). Four suites could not start their databases; other failures included two gateway tests loaded against earlier transformed authorization code, outdated role/resource fixtures, a cleanup deadlock, an HTTP timeout and a socket hang-up. All reported server failures and blocked startup suites subsequently passed fresh reruns. Workspace and serialized groups were executed separately after the runner's fail-fast exit. The original sweep is not represented as a clean pass.
+
+Qualification is complete for the connection feature and its provider fixtures. The repository-wide test result remains **not fully green** because the three CLI worktree tests could not initialize their additional PostgreSQL clusters at the host's shared-memory limit. They require rerunning on a host with spare PostgreSQL capacity. Live provider verification remains outside this delivery.
+
+## PR qualification (2026-10-26)
+
+The implementation was rebased onto `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`. Connection-service changes were ported into the current extracted modules. The historical checks above describe the October 13 snapshot; fresh PR qualification is recorded separately below.
diff --git a/packages/db/src/migrations/0378_slim_nehzno.sql b/packages/db/src/migrations/0378_slim_nehzno.sql
new file mode 100644
index 00000000000..69e2308817a
--- /dev/null
+++ b/packages/db/src/migrations/0378_slim_nehzno.sql
@@ -0,0 +1 @@
+ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
\ No newline at end of file
diff --git a/packages/db/src/migrations/meta/0373_snapshot.json b/packages/db/src/migrations/meta/0378_snapshot.json
similarity index 99%
rename from packages/db/src/migrations/meta/0373_snapshot.json
rename to packages/db/src/migrations/meta/0378_snapshot.json
index 3019f5fde87..e20238dadb6 100644
--- a/packages/db/src/migrations/meta/0373_snapshot.json
+++ b/packages/db/src/migrations/meta/0378_snapshot.json
@@ -1,6 +1,6 @@
{
- "id": "406521f8-5352-415b-805d-786854381e03",
- "prevId": "9c1b4cac-52f1-4c6e-89b3-c9a7816016c7",
+ "id": "6e5887cd-744d-41b9-8aef-a042582bc95e",
+ "prevId": "a0fcd9b8-f125-4985-8795-f567466b9aef",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -28508,6 +28508,13 @@
"primaryKey": false,
"notNull": true
},
+ "checkout_name": {
+ "name": "checkout_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
"object_key": {
"name": "object_key",
"type": "text",
@@ -28601,8 +28608,8 @@
"method": "btree",
"with": {}
},
- "execution_workspace_restore_points_live_generation_uniq": {
- "name": "execution_workspace_restore_points_live_generation_uniq",
+ "execution_workspace_restore_points_live_status_uniq": {
+ "name": "execution_workspace_restore_points_live_status_uniq",
"columns": [
{
"expression": "execution_workspace_id",
@@ -28615,10 +28622,16 @@
"isExpression": false,
"asc": true,
"nulls": "last"
+ },
+ {
+ "expression": "checkout_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
}
],
"isUnique": true,
- "where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'",
+ "where": "\"execution_workspace_restore_points\".\"status\" in ('pending', 'active', 'expiring')",
"concurrently": false,
"method": "btree",
"with": {}
@@ -55929,6 +55942,12 @@
"notNull": true,
"default": "''"
},
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
"owner_note": {
"name": "owner_note",
"type": "text",
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index c69031562fa..f51cf8567e5 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -2633,6 +2633,13 @@
"when": 1792790134086,
"tag": "0377_clear_settled_native_run_process_identity",
"breakpoints": true
+ },
+ {
+ "idx": 378,
+ "version": "7",
+ "when": 1793022592578,
+ "tag": "0378_slim_nehzno",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/chat_github.ts b/packages/db/src/schema/chat_github.ts
index 2b2fddae486..ebd1928ccaf 100644
--- a/packages/db/src/schema/chat_github.ts
+++ b/packages/db/src/schema/chat_github.ts
@@ -75,6 +75,8 @@ export const chatGitHubRegistrations = pgTable(
manifestClaimId?: string;
identityLeaseId?: string;
identityLeaseExpiresAt?: string;
+ browserSessionId?: string;
+ identitySessionId?: string;
identityStateHash?: string;
identityExpiresAt?: string;
identityRedemptionId?: string;
diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts
index 5f356e7a8d5..747828734e4 100644
--- a/packages/db/src/schema/tool_access.ts
+++ b/packages/db/src/schema/tool_access.ts
@@ -564,6 +564,8 @@ export const toolMcpGatewayTokens = pgTable(
subjectType: text("subject_type").$type().notNull().default("gateway_client"),
subjectId: text("subject_id"),
clientLabel: text("client_label").notNull().default(""),
+ // Null distinguishes board/legacy clients from delegated agent clients.
+ responsibleUserId: text("responsible_user_id"),
ownerNote: text("owner_note").notNull().default(""),
allowedActions: jsonb("allowed_actions").$type().notNull().default(["tools/list", "tools/call"]),
expiresAt: timestamp("expires_at", { withTimezone: true }),
diff --git a/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts b/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts
index 3d4e22a01a5..950787f5b91 100644
--- a/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts
+++ b/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts
@@ -1,4 +1,4 @@
-export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v7";
+export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v8";
export const PAPERCLIP_EXECUTION_PROMPT =
"You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. " +
@@ -6,7 +6,7 @@ export const PAPERCLIP_EXECUTION_PROMPT =
"When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. " +
"When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. " +
"When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. " +
- "For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. " +
+ "For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. For user-requested setup, use search_api and call_api to perform authorized connection, chat-channel, and AI-account configuration. Standard agents inherit connection management bounded by their responsible user and explicit restrictions. Reuse saved drafts. Hand off only missing decisions, human authentication, consent, or credential entry. Suggest a relevant connection when it helps. Do not silently relax an Off/Ask first policy after a failed call, approve your own action, or change your own principal permissions. Report saved, authenticated, usable, and verified separately. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. " +
"For a deferred check you own, call set_task_monitor with a future nextCheckAt and notes. Confirm the persisted schedule on the current task, then call paperclip_finish with reportedWorkDisposition yielded and continuation.kind monitor. End the turn; the one-shot monitor wakes you with issue_monitor_due. Re-arm explicitly only if another check is needed. Do not claim a monitor exists without its receipt. " +
"Finish exactly once with `paperclip_finish` or `paperclip_block`.";
diff --git a/packages/shared/src/connection-intent-guidance.test.ts b/packages/shared/src/connection-intent-guidance.test.ts
index 59ef1d0aa8f..76047c03750 100644
--- a/packages/shared/src/connection-intent-guidance.test.ts
+++ b/packages/shared/src/connection-intent-guidance.test.ts
@@ -3,13 +3,13 @@ import { CONNECTION_INTENT_AGENT_GUIDANCE, CONNECTION_REQUEST_TOOL_DESCRIPTION,
describe("connection result guidance", () => {
it("delegates route decisions to trusted tool output instead of a provider decision tree", () => {
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("follow the returned `instruction`");
- expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded user choices");
+ expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded declines and provider choices");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/Composio|Arcade|Executor|Zapier/);
expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/https?:\/\/|bearer/);
});
it("retains waiting, credential, and arbitrary URL boundaries", () => {
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("yield without retrying or polling");
- expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never invent access or ask for credentials in comments");
+ expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never ask for credentials in comments");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("arbitrary MCP URLs");
});
it("describes exact questions and saved selection proof", () => {
diff --git a/packages/shared/src/connection-intent-guidance.ts b/packages/shared/src/connection-intent-guidance.ts
index 8c63bb36c28..7178d4afddd 100644
--- a/packages/shared/src/connection-intent-guidance.ts
+++ b/packages/shared/src/connection-intent-guidance.ts
@@ -8,13 +8,15 @@
export const CONNECTION_INTENT_AGENT_GUIDANCE = [
"Connection tools:",
"- When the user asks to connect a service, call `connections_search` before any service tool, even if already installed. Also search when a task needs a service and usable access is uncertain. Search by its name or capability and follow the returned `instruction`.",
- "- Use the returned service identifiers and connection tools for setup. Respect recorded user choices; never invent access or ask for credentials in comments.",
- "- If installed tools are Off or the connection is not enabled for this agent, call `connection_request` with the saved connectionId and required toolNames. It creates a scoped human access card; never substitute a generic permission question or change permissions yourself.",
+ "- Distinguish tool, chat/channel, and AI account purposes. Use `search_api` and `call_api` for authorized connection configuration requested by the user. Standard agents can manage connections under their responsible user's authority; inspect the returned capabilities and respect explicit restrictions. Reuse saved accounts and setup drafts.",
+ "- Suggest a relevant connection when it would help the task and usable access is missing. Respect recorded declines and provider choices. Ask only for missing decisions, consent, credentials, or authority; never ask for credentials in comments. Hand off the exact required human action at the saved setup link, then inspect status and continue through the API.",
+ "- If a tool call is blocked by Off/Ask first or missing installation access, use `connection_request` with the saved connectionId and required toolNames. Do not silently relax policy to work around a failed call. You may perform an access-policy change explicitly requested by the user when authorized; never approve your own governed action or change your own principal permissions.",
+ "- Report saved, authenticated, usable, and verified as separate states. A saved connection is not proof of successful authentication or a verified provider action.",
"- When waiting for user action, finish independent work, then yield without retrying or polling. On continuation, follow the recorded outcome and use the installed connection.",
"- Do not use connection tools for arbitrary MCP URLs or unrelated work.",
].join("\n");
-export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or a natural-language description (up to 4000 characters). Results tolerate extra words, split names, and small typos. Choose the relevant match using its description and method purpose. Follow the returned instruction to present an inline setup card with connection_request or share the method's setupPath. Also discovers verified external aggregator apps. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only.";
+export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or natural-language description (up to 4000 characters). Choose the relevant method and inspect its management capabilities. For user-requested configuration, discover and call the existing REST operation with search_api/call_api; reuse saved drafts and hand off only required human authentication or consent. Use connection_request for missing access or required user input. Also discovers verified external aggregator apps. Use first when asked to connect a service or usable access is uncertain; respect the returned instruction, recorded declines, and exact providerQuestion. Do not use for arbitrary MCP URLs. Search is read-only.";
export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search. For an existing connection with missing agent access, pass connectionId and the exact required toolNames to create an embedded Grant access card; only the human can approve it. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments.";
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 5ad42e5a7a1..604714ca93d 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -70,6 +70,7 @@ export type {
ConnectionSearchResultItem,
ConnectionsSearchResult,
ConnectionRequestResult,
+ ConnectionRequiredUserAction,
ConnectionIntentSetupOptions,
ConnectionIntentSetupConnection,
CompleteConnectionIntentInput,
diff --git a/packages/shared/src/types/connection-intent.ts b/packages/shared/src/types/connection-intent.ts
index 652d0b30358..fe314b98f4c 100644
--- a/packages/shared/src/types/connection-intent.ts
+++ b/packages/shared/src/types/connection-intent.ts
@@ -3,6 +3,17 @@ import type { ToolConnection } from "./tool-access.js";
import type { AskUserQuestionsQuestion, PaperclipQuestionSetPayload } from "./issue.js";
import type { RemoteMcpConnectorId } from "../remote-mcp-connectors.js";
+/** A saved setup's next human step. This is not an authorization grant. */
+export interface ConnectionRequiredUserAction {
+ kind: "authenticate" | "consent" | "enter_credentials" | "confirm_identity";
+ userId: string;
+ reason: string;
+ resource: { type: "connection" | "chat_endpoint" | "ai_login"; id: string; companyId: string };
+ setupPath: string;
+ expiresAt?: string;
+ continuation?: string;
+}
+
export type ConnectionAvailabilityState =
| "ready"
| "needs_user_action"
@@ -32,6 +43,15 @@ export interface ConnectionSearchResultItem {
purpose?: "tool" | "channel" | "ai";
/** Company-scoped setup destination for methods with a separate setup flow. */
setupPath?: string;
+ management?: {
+ allowed: boolean;
+ reason: string;
+ operation: string;
+ canUse: boolean;
+ requiresApproval?: boolean;
+ requiresHumanAuthentication: boolean;
+ nextApiOperation: string;
+ };
}>;
state: ConnectionAvailabilityState;
connectionId: string | null;
@@ -62,6 +82,7 @@ export interface ConnectionRequestResult {
export type ConnectionIntentSetupConnection = Pick;
export interface ConnectionIntentSetupOptions {
+ chatSetup?: { path: string; endpointId: string; connectionId: string };
/** Resume this request's saved AgentMail account after a partial setup. */
emailSetup?: { credentialConnectionId: string | null; readyConnectionId: string | null };
canGrantAccess?: boolean;
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 48f9fe6b6f4..b90bd4ba304 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -12,6 +12,7 @@ export type {
ConnectionSearchResultItem,
ConnectionsSearchResult,
ConnectionRequestResult,
+ ConnectionRequiredUserAction,
ConnectionIntentSetupOptions,
ConnectionIntentSetupConnection,
CompleteConnectionIntentInput,
diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index cb6b7a5b622..ff62dfcd67b 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -1448,6 +1448,8 @@ export type ConnectionIntentPhase = "requested" | "authorizing" | "needs_retry";
* authorization URLs are returned solely from addressed board endpoints.
*/
export interface ConnectionIntentPayload {
+ /** Existing chat setup; the server derives the destination from these IDs. */
+ savedSetup?: { endpointId: string; connectionId: string };
version: 1;
/** Server-authored consent to a fixed set of tools on an existing connection. */
accessRequest?: {
diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts
index cfd1c1bf8e3..e137714ebb3 100644
--- a/packages/shared/src/types/tool-access.ts
+++ b/packages/shared/src/types/tool-access.ts
@@ -302,6 +302,8 @@ export interface ConnectionGrantCapabilities {
* not "render disabled".
*/
export interface ToolConnectionCapabilities {
+ management?: { allowed: boolean; reason: string; operation: string };
+ usage?: { allowed: boolean; reason: string; operation: string; requiresApproval?: boolean; allowedCount?: number; askFirstCount?: number };
canConfigure: boolean;
canCreateOrganizationGrant: boolean;
canSetCompanyInstall: boolean;
@@ -745,6 +747,8 @@ export interface ToolMcpGatewayToken {
revokedAt: Date | string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
+ /** Agent client delegation. Null with an issuing agent identifies an unbound legacy client. */
+ responsibleUserId?: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
@@ -1234,7 +1238,8 @@ export interface ToolOAuthStartResult {
connectionId: string;
provider: string;
authorizationUrl: string;
- expiresAt: string;
+ /** Null until the addressed human starts provider authorization from a saved setup. */
+ expiresAt: string | null;
/**
* Opaque Paperclip Cloud authorization handoff. The board submits this only
* to its fixed same-origin Cloud endpoint; it is never treated as a URL.
diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts
index 9e42c7b1187..9a125d00b86 100644
--- a/packages/shared/src/validators/issue.ts
+++ b/packages/shared/src/validators/issue.ts
@@ -1170,6 +1170,7 @@ const connectionIntentBrandAssetSchema = z
export const connectionIntentPayloadSchema = z
.object({
+ savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid() }).strict().optional(),
accessRequest: z.object({
connectionId: z.string().guid(),
connectionName: z.string().trim().min(1).max(160),
diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts
index 88279b91f2e..ef61c099a02 100644
--- a/server/src/__tests__/agent-hire-ai-connections.test.ts
+++ b/server/src/__tests__/agent-hire-ai-connections.test.ts
@@ -23,6 +23,7 @@ import { aiConnectionRouterService } from "../services/ai-connection-router.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { toolAccessService } from "../services/tool-access.js";
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
+import { accessService } from "../services/access.js";
import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
const captureRunFailure = vi.hoisted(() => vi.fn());
@@ -61,6 +62,7 @@ async function fixture(provider: "anthropic" | "openai", method: "api_key" | "su
const companyId = randomUUID();
const agentId = randomUUID();
const userId = `owner-${companyId}`;
+ await db.insert(authUsers).values({ id: userId, name: "Manager owner", email: `${userId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
const binding = { provider, method, mode: "responsible_user" } as const;
const adapterType = provider === "anthropic" ? "claude_local" : "codex_local";
await db.insert(authUsers).values({ id: userId, name: "Connection owner", email: `${userId}@example.test`,
@@ -69,6 +71,8 @@ async function fixture(provider: "anthropic" | "openai", method: "api_key" | "su
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:create" });
await db.insert(agents).values({ id: agentId, companyId, name: "Manager", role: "ceo", adapterType, runtimeConfig: { aiConnection: binding } });
+ await accessService(db).ensureMembership(companyId, "agent", agentId, "member", "active");
+ await db.insert(principalPermissionGrants).values(["tools:manage_profiles", "agents:configure"].map(permissionKey => ({ companyId, principalType: "agent" as const, principalId: agentId, permissionKey })));
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", responsibleUserId: userId }).returning();
const app = express();
app.use(express.json());
diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts
index bb5686170bc..329feb58dbc 100644
--- a/server/src/__tests__/ai-connection-router.test.ts
+++ b/server/src/__tests__/ai-connection-router.test.ts
@@ -115,6 +115,7 @@ describe("durable, authorized connection routing", () => {
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, members[0]!.binding.connectionId)))[0]?.status).toBe("active");
});
it("requires company connection management authority on pool configuration routes", async () => {
+ await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice")));
const app = express(); app.use(express.json());
let actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] };
app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); }); app.use("/api", aiConnectionRoutes(db)); app.use(errorHandler);
@@ -122,6 +123,7 @@ describe("durable, authorized connection routing", () => {
expect((await request(app).get(url)).status).toBe(200);
expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connection-pools`)).status).toBe(403);
expect((await request(app).delete(`/api/companies/${otherCompanyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
+ await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice")));
actor = { ...actor, memberships: [{ companyId, status: "active", membershipRole: "viewer" }] };
expect((await request(app).get(url)).status).toBe(403);
expect((await request(app).post(url).send({ pluginKey, config: pool })).status).toBe(403);
@@ -130,10 +132,12 @@ describe("durable, authorized connection routing", () => {
actor = { type: "agent", agentId, companyId, source: "agent_jwt" } as unknown as typeof actor;
expect((await request(app).get(url)).status).toBe(403);
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403);
+ await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice")));
actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] };
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409);
expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(200);
expect((await request(app).get(url)).body.some((entry: { id: string }) => entry.id === pool.id)).toBe(false);
+ await db.update(companyMemberships).set({ membershipRole: "member" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice")));
});
it("deletes pools without erasing pins, cursors, or admitted recovery evidence", async () => {
const pool = await makePool(); const selected = await resolve(pool.id, "retained");
diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts
index f844a18bce3..5fa01500bf4 100644
--- a/server/src/__tests__/chat-channels.integration.test.ts
+++ b/server/src/__tests__/chat-channels.integration.test.ts
@@ -9712,6 +9712,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
});
expect(rows[0]!.details).toEqual({
+ permissionDecision: expect.objectContaining({ operation: "PUT /chat-endpoints/:endpointId/resources" }),
endpointId: endpoint.id,
provider: "slack",
changes: [
@@ -10198,7 +10199,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
];
for (const mutation of deniedMutations) {
const response = await mutation.expect(403);
- expect(response.body.error).toBe(
+ expect(response.body.error).toContain(
"Missing permission: tools:manage_connections",
);
}
diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts
new file mode 100644
index 00000000000..286b38b5305
--- /dev/null
+++ b/server/src/__tests__/connection-management.integration.test.ts
@@ -0,0 +1,498 @@
+import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared";
+import { randomUUID, generateKeyPairSync } from "node:crypto";
+import { join } from "node:path";
+import { and, eq } from "drizzle-orm";
+import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
+import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGatewayTokens, plugins } from "@paperclipai/db";
+import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
+import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
+import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
+import { createLocalAgentJwt } from "../agent-auth-jwt.js";
+import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
+import { configureAgentLifecycle } from "../services/agent-lifecycle.js";
+import { agentService } from "../services/agents.js";
+import { accessService } from "../services/access.js";
+import { instanceSettingsService } from "../services/instance-settings.js";
+import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
+
+// All requests traverse the assembled server and production bearer middleware.
+// No req.actor injection or special permission grants establish access.
+describe("connection management with production default agents", () => {
+ let server: Awaited>;
+ let lifecycle: ReturnType;
+ let provider: ((url: string, init?: RequestInit) => Promise) | null = null;
+ let providerFetch: ReturnType;
+ beforeAll(async () => {
+ vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID());
+ vi.stubEnv("PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE", "shadow");
+ vi.stubEnv("PAPERCLIP_AUTH_RATE_LIMIT_ENABLED", "false");
+ const realFetch = globalThis.fetch;
+ providerFetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
+ const url = String(input);
+ if (new URL(url).hostname === "127.0.0.1") return realFetch(input, init);
+ if (provider) return provider(url, init);
+ throw new Error("Unexpected external request in provider fixture");
+ });
+ server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true });
+ lifecycle = configureAgentLifecycle(server.db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" });
+ vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(server.root, "master.key"));
+ await instanceSettingsService(server.db).updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: true });
+ }, 90_000);
+ afterAll(async () => { await lifecycle?.stop(); await server?.close(); providerFetch?.mockRestore(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
+
+ async function fixture() {
+ const base = await server.fixture({ connectionScenario: "fresh" });
+ const userId = base.responsibleUserId!;
+ const access = accessService(server.db);
+ await access.ensureMembership(base.companyId, "user", userId, "operator", "active");
+ await access.ensureRoleDefaultGrants(base.companyId, userId, "operator", userId);
+ await server.db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, base.companyId));
+ const cookie = await server.humanSession(userId);
+ const created = await fetch(`${server.apiUrl}/api/companies/${base.companyId}/agents`, {
+ method: "POST", headers: { cookie, "Content-Type": "application/json", origin: server.apiUrl },
+ body: JSON.stringify({ name: "Fresh standard agent", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" },
+ runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } }, permissions: {} }),
+ });
+ const agent = await created.json();
+ expect(agent, JSON.stringify(agent)).toHaveProperty("id");
+ expect(created.status).toBe(201);
+ await lifecycle.process(agent.id);
+ expect((await agentService(server.db).getById(agent.id))?.status).toBe("idle");
+ await server.db.update(heartbeatRuns).set({ agentId: agent.id }).where(eq(heartbeatRuns.id, base.runId));
+ await server.db.update(issues).set({ assigneeAgentId: agent.id, responsibleUserId: userId }).where(eq(issues.id, base.issueId));
+ const key = await agentService(server.db).createApiKey(agent.id, "integration", { kind: "standard" }, { responsibleUserId: userId });
+ const jwt = createLocalAgentJwt(agent.id, base.companyId, "paperclip_runner", base.runId, userId);
+ const request = async (method: string, path: string, body?: unknown, token = key.token) => {
+ const response = await fetch(server.apiUrl + (path.startsWith("/mcp/") ? "" : "/api") + path, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ return { status: response.status, body: await response.json().catch(() => null) };
+ };
+ return { ...base, agentId: agent.id, userId, key, jwt, request,
+ authority: new PaperclipRunnerToolAuthority(server.db, { companyId: base.companyId, agentId: agent.id, issueId: base.issueId, runId: base.runId, apiUrl: server.apiUrl, storage: server.storage }) };
+ }
+
+ it("creates and configures tool connections and profiles with fresh grants and actual key/JWT authentication", async () => {
+ const f = await fixture();
+ const grants = await server.db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.agentId));
+ expect(grants.some(g => g.permissionKey === "tools:admin")).toBe(false);
+ const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Fixture tool", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
+ expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
+ expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "Updated tool" }, f.jwt)).toMatchObject({ status: 200, body: { name: "Updated tool" } });
+ const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
+ expect(saved.createdByUserId).toBe(f.userId);
+ const audit = await server.db.select().from(activityLog).where(eq(activityLog.entityId, saved.id));
+ expect(audit).toEqual(expect.arrayContaining([expect.objectContaining({ actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId,
+ details: expect.objectContaining({ permissionDecision: expect.objectContaining({ action: "connections:configure", reason: expect.stringMatching(/^allow_/) }) }),
+ })]));
+ const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "fixture-profile", name: "Fixture profile", defaultAction: "deny", entries: [] });
+ expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
+ });
+
+ it("hands generic app authentication to the saved setup without creating agent-bound OAuth state", async () => {
+ const f = await fixture();
+ const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
+ name: "OAuth handoff fixture", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft",
+ transportConfig: { url: "https://1.1.1.1/mcp" },
+ });
+ expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
+ const path = `/tools/oauth/${created.body.id}/start`;
+ const started = await f.request("POST", path, { asCurrentUser: true });
+ expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: {
+ expiresAt: null, requiredUserAction: { userId: f.userId, resource: { id: created.body.id } },
+ } });
+ expect(new URL(started.body.authorizationUrl).searchParams.get("resume")).toBe(created.body.id);
+ expect(await f.request("POST", path, { asCurrentUser: true })).toMatchObject({ status: 200, body: { authorizationUrl: started.body.authorizationUrl } });
+ expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, created.body.id))).toHaveLength(0);
+ });
+
+ it("delegates and revokes an owned credential with actual agent attribution", async () => {
+ const f = await fixture();
+ const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
+ name: "Personal delegation fixture", transport: "mcp_remote", authKind: "none", credentialPolicy: "per_user",
+ transportConfig: { url: "https://1.1.1.1/mcp" },
+ });
+ expect(created.status).toBe(201);
+ // Provider fixture: a completed personal credential, not principal permissions.
+ const [grant] = await server.db.insert(connectionGrants).values({ companyId: f.companyId,
+ connectionId: created.body.id, kind: "user", subjectUserId: f.userId, status: "active",
+ credentialSecretRefs: [], createdByUserId: f.userId,
+ }).returning();
+ const path = `/tool-connections/${created.body.id}/grants/${grant.id}/delegations`;
+ const delegated = await f.request("POST", path, { agentId: f.agentId });
+ expect(delegated, JSON.stringify(delegated)).toMatchObject({ status: 201 });
+ expect(await f.request("POST", path, { agentId: f.agentId })).toMatchObject({ status: 201, body: { id: delegated.body.id } });
+ expect(await f.request("DELETE", `${path}/${delegated.body.id}`)).toMatchObject({ status: 200 });
+ expect(await server.db.select().from(connectionGrantDelegations).where(eq(connectionGrantDelegations.grantId, grant.id))).toHaveLength(0);
+ const events = await server.db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, created.body.id));
+ expect(events).toEqual(expect.arrayContaining([
+ expect.objectContaining({ action: "connection_grant.delegated", actorType: "agent", actorId: f.agentId }),
+ expect.objectContaining({ action: "connection_grant.delegation_revoked", actorType: "agent", actorId: f.agentId }),
+ ]));
+ const activity = await server.db.select().from(activityLog).where(eq(activityLog.entityId, grant.id));
+ expect(activity).toEqual(expect.arrayContaining([expect.objectContaining({ actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId })]));
+ });
+
+ it("discovers and invokes connection APIs through default production native bindings", async () => {
+ const f = await fixture();
+ const definitions = await f.authority.definitions();
+ const names = definitions.map(d => d.name);
+ const context = await f.authority.execute({ tool: "get_task_context", callId: "configuration-guidance", arguments: {} });
+ expect(JSON.stringify(context)).toContain("Standard agents can manage connections under their responsible user's authority");
+ expect(JSON.stringify(definitions.find(d => d.name === "connections_search"))).toContain("search_api/call_api");
+ expect(names).toEqual(expect.arrayContaining(["search_api", "call_api"]));
+ const search = await f.authority.execute({ tool: "search_api", callId: "find-profile", arguments: { query: "create tools profiles", limit: 10 } });
+ expect(JSON.stringify(search)).toContain("POST /api/companies/{companyId}/tools/profiles");
+ const result = await f.authority.execute({ tool: "call_api", callId: "create-profile", arguments: { operationId: "POST /api/companies/{companyId}/tools/profiles", body: { profileKey: "native-profile", name: "Native profile", entries: [] } } });
+ expect(result, JSON.stringify(result)).toMatchObject({ status: 201, ok: true });
+ });
+
+ it("persists meaningful Slack and GitHub endpoint configuration for the responsible user", async () => {
+ const f = await fixture();
+ for (const provider of ["slack", "github"]) {
+ const result = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider, assignedAgentId: f.agentId, name: `${provider} fixture`, ...(provider === "slack" ? { slackSetupMethod: "manual" } : {}) });
+ expect(result, JSON.stringify(result)).toMatchObject({ status: 201 });
+ if (provider === "slack") {
+ const patch = { requireAtMention: true, allowDirectMessages: false };
+ const updated = await f.request("PATCH", `/chat-endpoints/${result.body.id}`, patch);
+ expect(updated, JSON.stringify(updated)).toMatchObject({ status: 200, body: patch });
+ } else {
+ const current = await f.request("GET", `/chat-endpoints/${result.body.id}/github/configuration`);
+ expect(current, JSON.stringify(current)).toMatchObject({ status: 200 });
+ const body = { expectedRevision: current.body.revision, configuration: { ...current.body.configuration, memberAccess: "selected" } };
+ const updated = await f.request("PUT", `/chat-endpoints/${result.body.id}/github/configuration`, body);
+ expect(updated, JSON.stringify(updated)).toMatchObject({ status: 200, body: { revision: 1, configuration: { memberAccess: "selected" } } });
+ expect(await f.request("PUT", `/chat-endpoints/${result.body.id}/github/configuration`, body)).toMatchObject({ status: 409 });
+ const handoff = await f.request("POST", `/chat-endpoints/${result.body.id}/github/identity/start`, {});
+ expect(handoff).toMatchObject({ status: 200, body: { requiredUserAction: { userId: f.userId, resource: { id: result.body.id } } } });
+ }
+ expect((await f.request("GET", `/chat-endpoints/${result.body.id}`)).body.sponsorUserId).toBe(f.userId);
+ }
+ });
+
+ it("configures Slack against provider fixtures and subjects test messages to the normal action policy", async () => {
+ const f = await fixture();
+ const botId = `U${f.agentId.replaceAll("-", "").toUpperCase()}`;
+ const scopes = [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES];
+ let sent = 0;
+ provider = async (url, init) => {
+ if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } });
+ if (url.includes("conversations.list")) return Response.json({ ok: true, channels: [], response_metadata: { next_cursor: "" } });
+ if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: "UHUMAN", team_id: "TFIXTURE", name: "Fixture user", is_bot: false, deleted: false } });
+ if (url.endsWith("chat.postMessage")) { sent++; return Response.json({ ok: true, channel: "DTEST", ts: "1.000001" }); }
+ if (url.endsWith("agents.sessions.setStatus")) return Response.json({ ok: true });
+ throw new Error(`Unexpected Slack fixture operation: ${new URL(url).pathname}`);
+ };
+ try {
+ const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "slack", assignedAgentId: f.agentId, slackSetupMethod: "manual" });
+ const configured = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } });
+ expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 });
+ expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token");
+ // Identity is a provider fixture; production HTTP admission and policy remain real.
+ const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning();
+ await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() });
+ await server.db.update(chatEndpoints).set({ status: "active", allowDirectMessages: true }).where(eq(chatEndpoints.id, created.body.id));
+ const [deny] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Test messages off", policyType: "block", selectors: { connectionId: created.body.connectionId }, priority: 1 }).returning();
+ const testPath = `/chat-endpoints/${created.body.id}/slack/test-message`;
+ const denied = await f.request("POST", testPath, { requestId: randomUUID() });
+ expect(denied, JSON.stringify(denied)).toMatchObject({ status: 403 });
+ expect(sent).toBe(0);
+ await server.db.update(toolPolicies).set({ policyType: "allow" }).where(eq(toolPolicies.id, deny.id));
+ const requestId = randomUUID();
+ const tested = await f.request("POST", testPath, { requestId });
+ expect(tested, JSON.stringify(tested)).toMatchObject({ status: 200, body: { status: "sent" } });
+ expect(await f.request("POST", testPath, { requestId })).toMatchObject({ status: 200 });
+ expect(sent).toBe(1);
+ } finally { provider = null; }
+ });
+
+ it("imports a GitHub App and discovers repositories against provider fixtures", async () => {
+ const f = await fixture();
+ const permissions = { contents: "read", issues: "write", metadata: "read", pull_requests: "write" };
+ const appId = 100000 + Number.parseInt(f.agentId.slice(0, 6), 16);
+ const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs8", format: "pem" }).toString();
+ provider = async (url, init) => {
+ const path = new URL(url).pathname;
+ if (path === "/login/oauth/access_token") return Response.json({ access_token: "fixture-personal-token" });
+ if (path === "/user") return Response.json({ id: 1357, login: "fixture-person" });
+ if (path === "/user/installations") return Response.json({ installations: [{ id: 2468, app_id: appId }] });
+ if (path === "/app") return Response.json({ id: appId, slug: `fixture-${f.agentId.slice(0, 8)}`, name: "Fixture App", owner: { login: "fixture" }, permissions,
+ events: ["pull_request", "github_app_authorization", "installation", "installation_repositories", "issue_comment", "pull_request_review_comment"] });
+ if (path === "/app/hook/config") return Response.json(init?.body ? JSON.parse(String(init.body)) : { url: "https://paperclip.fixture.invalid", content_type: "json", insecure_ssl: "0" });
+ if (path === "/app/installations") return Response.json([{ id: 2468, account: { id: 1357, login: "fixture", type: "Organization" }, permissions, suspended_at: null }]);
+ if (path === "/app/installations/2468") return Response.json({ permissions, suspended_at: null });
+ if (path === "/app/installations/2468/access_tokens") return Response.json({ token: "fixture-installation-token" }, { status: 201 });
+ if (path === "/installation/repositories") return Response.json({ repositories: [{ id: 42, name: "repo", full_name: "fixture/repo", private: true, owner: { login: "fixture" }, html_url: "https://github.com/fixture/repo" }] });
+ throw new Error(`Unexpected GitHub fixture operation: ${path}`);
+ };
+ try {
+ const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
+ expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/registration`, { name: `fixture-${f.agentId.slice(0, 8)}` })).toMatchObject({ status: 200 });
+ const imported = await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, { appId: String(appId), privateKey, webhookSecret: "fixture-webhook-secret", clientId: "fixture-client", clientSecret: "fixture-secret" });
+ expect(imported, JSON.stringify(imported)).toMatchObject({ status: 200 });
+ expect(JSON.stringify(imported.body)).not.toContain("PRIVATE KEY");
+ const refreshed = await f.request("POST", `/chat-endpoints/${created.body.id}/github/repositories/refresh`, {});
+ expect(refreshed, JSON.stringify(refreshed)).toMatchObject({ status: 200 });
+ expect(JSON.stringify(refreshed.body)).toContain("fixture/repo");
+ expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/verify`, {})).toMatchObject({ status: 200 });
+ const cookie = await server.humanSession(f.userId);
+ const humanRequest = (path: string, method = "GET", body?: unknown, session = cookie) => fetch(server.apiUrl + path, {
+ method, redirect: "manual", headers: { cookie: session, "Content-Type": "application/json", origin: server.apiUrl },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ const start = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/start`, "POST", {});
+ const started = await start.json();
+ expect(started, JSON.stringify(started)).toHaveProperty("authorizationUrl");
+ const state = new URL(started.authorizationUrl).searchParams.get("state")!;
+ const callback = `/api/chat-github/identity/callback?state=${encodeURIComponent(state)}&code=fixture-code`;
+ const other = await fixture();
+ const otherCookie = await server.humanSession(other.userId);
+ expect((await humanRequest(callback, "GET", undefined, otherCookie)).status).toBe(403);
+ expect((await humanRequest(callback)).status).toBe(303);
+ expect((await humanRequest(callback)).status).toBe(403);
+ const confirmed = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/confirm`, "POST", { githubUserId: "1357" });
+ expect(confirmed.status, JSON.stringify(await confirmed.json())).toBe(200);
+ } finally { provider = null; }
+ });
+
+ it("creates an email account through the API without exposing its write-only credential and reuses retries", async () => {
+ const f = await fixture();
+ provider = async url => {
+ if (new URL(url).pathname === "/v0/auth/me") return Response.json({ scope_type: "organization", organization_id: "fixture-org" });
+ throw new Error(`Unexpected AgentMail fixture operation: ${new URL(url).pathname}`);
+ };
+ try {
+ const body = { apiKey: "fixture-agentmail-secret", grantKind: "user", agentIds: [f.agentId], allAgents: false, idempotencyKey: randomUUID() };
+ const created = await f.request("POST", `/companies/${f.companyId}/email/connections`, body);
+ expect(created, JSON.stringify(created)).toMatchObject({ status: 201 });
+ expect(JSON.stringify(created.body)).not.toContain(body.apiKey);
+ expect(await f.request("POST", `/companies/${f.companyId}/email/connections`, body)).toMatchObject({ status: 201, body: { id: created.body.id } });
+ expect((await f.request("GET", `/companies/${f.companyId}/email/connections`)).body).toEqual(expect.arrayContaining([expect.objectContaining({ id: created.body.id })]));
+ const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id));
+ expect(saved.createdByUserId).toBe(f.userId);
+ } finally { provider = null; }
+ });
+
+ it("keeps durable gateway client tokens bound to the issuer and responsible user", async () => {
+ const f = await fixture();
+ const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-profile", name: "Gateway profile", entries: [] });
+ expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
+ const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Delegated gateway", slug: "delegated", profileId: profile.body.id });
+ expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 });
+ const call = { tool: "call_api", callId: "gateway-client-secret", arguments: {
+ operationId: "POST /api/tool-gateway/gateways/{gatewayId}/tokens", pathParams: { gatewayId: gateway.body.id },
+ body: { companyId: f.companyId, name: "Fixture client", clientLabel: "fixture", expiresAt: new Date(Date.now() + 3600000).toISOString() },
+ } };
+ const issued = await f.authority.execute(call) as { status: number; data: { id: string; token: string } };
+ expect(issued, JSON.stringify(issued)).toMatchObject({ status: 201, data: { token: expect.any(String), responsibleUserId: f.userId } });
+ expect(await f.authority.execute(call)).toEqual(issued);
+ const token = { status: issued.status, body: issued.data };
+ const [receiptRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
+ expect(JSON.stringify(receiptRun.resultJson)).not.toContain(token.body.token);
+ const receipts = (receiptRun.resultJson as { apiToolReceipts: Record }).apiToolReceipts;
+ expect(Object.values(receipts).find(receipt => receipt.operationId === call.arguments.operationId)).toHaveProperty("encryptedResult");
+ expect(JSON.stringify(redactRunnerApiPayloads(issued))).not.toContain(token.body.token);
+ expect(await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.gatewayId, gateway.body.id))).toHaveLength(1);
+ const [saved] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id));
+ expect(saved).toMatchObject({ createdByAgentId: f.agentId, responsibleUserId: f.userId, subjectType: "gateway_client" });
+ const use = () => f.request("POST", `/mcp/gateways/${gateway.body.gatewayPublicId}`, { jsonrpc: "2.0", id: 1, method: "tools/list" }, token.body.token);
+ expect(await use()).toMatchObject({ status: 200 });
+ await server.db.update(principalPermissionGrants).set({ scope: { purpose: "tool" } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect(await use()).toMatchObject({ status: 200 });
+ await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
+ expect(await use()).toMatchObject({ status: 200 });
+ await server.db.update(companyMemberships).set({ status: "suspended" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId)));
+ expect((await use()).status).toBeGreaterThanOrEqual(400);
+ await server.db.update(companyMemberships).set({ status: "active" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId)));
+ expect(await use()).toMatchObject({ status: 200 });
+ await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect((await use()).status).toBeGreaterThanOrEqual(400);
+ // Model the token FK's ON DELETE SET NULL state without deleting the
+ // fixture's protected historical activity references to the agent.
+ await server.db.update(toolMcpGatewayTokens).set({ createdByAgentId: null }).where(eq(toolMcpGatewayTokens.id, token.body.id));
+ const [orphaned] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id));
+ expect(orphaned).toMatchObject({ createdByAgentId: null, responsibleUserId: f.userId });
+ expect((await use()).status).toBeGreaterThanOrEqual(400);
+ });
+
+ it("requires retained profile authority to clear gateway bindings and connection audiences", async () => {
+ const f = await fixture();
+ const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "bound-profile", name: "Bound profile", entries: [] });
+ expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
+ const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Bound gateway", slug: "bound", profileId: profile.body.id, agentId: f.agentId });
+ expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201, body: { agentId: f.agentId } });
+ await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, agentId: null })).toMatchObject({ status: 403 });
+ expect(await f.request("GET", `/companies/${f.companyId}/tools/gateways`)).toMatchObject({ status: 200, body: { gateways: expect.arrayContaining([expect.objectContaining({ id: gateway.body.id, agentId: f.agentId })]) } });
+ for (const audience of [{ allAgents: false }, { agentIds: [] }]) {
+ expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, {
+ provider: "openai", method: "api_key", name: "No audience authority", apiKey: "unused-fixture-secret", ...audience,
+ })).toMatchObject({ status: 403 });
+ }
+ });
+
+ it("requires access to saved and replacement gateway profiles and project bindings", async () => {
+ const f = await fixture();
+ const saved = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "saved-scope", name: "Saved scope", entries: [] });
+ const replacement = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "replacement-scope", name: "Replacement scope", entries: [] });
+ expect(saved.status).toBe(201);
+ expect(replacement.status).toBe(201);
+ expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${saved.body.id}/bind`, { targetType: "project", targetId: f.projectId })).toMatchObject({ status: 201 });
+ const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Scoped gateway", slug: "scoped", profileId: saved.body.id });
+ expect(gateway.status).toBe(201);
+ await server.db.update(principalPermissionGrants).set({ scope: { profileIds: [replacement.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, profileId: replacement.body.id })).toMatchObject({ status: 403 });
+ // A grant covering both sides permits the same requested change.
+ await server.db.update(principalPermissionGrants).set({ scope: { profileIds: [saved.body.id, replacement.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, profileId: replacement.body.id })).toMatchObject({ status: 200, body: { profileId: replacement.body.id } });
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: f.projectId })).toMatchObject({ status: 200 });
+ const nextProjectId = randomUUID();
+ await server.db.insert(projects).values({ id: nextProjectId, companyId: f.companyId, name: "Replacement project" });
+ await server.db.update(principalPermissionGrants).set({ scope: { projectIds: [f.projectId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: nextProjectId })).toMatchObject({ status: 403 });
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: null })).toMatchObject({ status: 403 });
+ await server.db.update(principalPermissionGrants).set({ scope: { projectIds: [f.projectId, nextProjectId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: nextProjectId })).toMatchObject({ status: 200, body: { projectId: nextProjectId } });
+ await server.db.update(principalPermissionGrants).set({ scope: null }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
+ expect(await f.request("PATCH", `/tool-gateway/gateways/${gateway.body.id}`, { companyId: f.companyId, projectId: null })).toMatchObject({ status: 200, body: { projectId: null } });
+ });
+
+ it("saves an AI account with fixture verification under the user's ownership and agent attribution", async () => {
+ const f = await fixture();
+ provider = async url => {
+ if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] });
+ throw new Error("Unexpected AI fixture request");
+ };
+ try {
+ const account = await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "Fixture OpenAI", ownership: "personal", apiKey: "fixture-secret-do-not-return", agentIds: [f.agentId] });
+ expect(account, JSON.stringify(account)).toMatchObject({ status: 201 });
+ expect(JSON.stringify(account)).not.toContain("fixture-secret-do-not-return");
+ const list = await f.request("GET", `/companies/${f.companyId}/ai-connections`);
+ expect(list).toMatchObject({ status: 200, body: { currentUserId: f.userId } });
+ const [row] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, account.body.connectionId));
+ expect(row.createdByUserId).toBe(f.userId);
+ const binding = { mode: "delegated", provider: "openai", method: "api_key", ...account.body };
+ const bound = await f.request("PATCH", `/agents/${f.agentId}`, { runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false }, aiConnection: binding } });
+ expect(bound, JSON.stringify(bound)).toMatchObject({ status: 200 });
+ await instanceSettingsService(server.db).updateExperimental({ enableAiConnectionRouters: true });
+ const pluginKey = `fixture.router.${f.agentId}`;
+ await server.db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "0.1.0", status: "ready", manifestJson: {
+ id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Fixture router", description: "Fixture", author: "Tests",
+ categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "Fixture pool", description: "Use saved accounts" }, entrypoints: { worker: "worker.js" },
+ } });
+ const config = { name: "Fixture pool", enabled: true, mode: "round_robin", thresholdPercent: 90,
+ members: [{ id: randomUUID(), binding, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "low" } }] };
+ const pool = await f.request("POST", `/companies/${f.companyId}/ai-connection-pools`, { pluginKey, config });
+ expect(pool, JSON.stringify(pool)).toMatchObject({ status: 200, body: { revision: 1 } });
+ const changed = await f.request("POST", `/companies/${f.companyId}/ai-connection-pools`, { pluginKey, id: pool.body.id, expectedRevision: 1, config: { ...config, name: "Changed pool" } });
+ expect(changed, JSON.stringify(changed)).toMatchObject({ status: 200, body: { revision: 2 } });
+ expect(await f.request("DELETE", `/companies/${f.companyId}/ai-connection-pools/${pool.body.id}`, { expectedRevision: 1 })).toMatchObject({ status: 409 });
+ expect(await f.request("DELETE", `/companies/${f.companyId}/ai-connection-pools/${pool.body.id}`, { expectedRevision: 2 })).toMatchObject({ status: 200 });
+ await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
+ expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "No sharing authority", ownership: "personal", apiKey: "fixture-secret", agentIds: [f.agentId] })).toMatchObject({ status: 403 });
+ } finally { provider = null; }
+ });
+
+ it("reuses a saved chat authentication request and resumes only while its authority remains valid", async () => {
+ const f = await fixture();
+ const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId });
+ expect(created.status).toBe(201);
+ const path = `/chat-endpoints/${created.body.id}/github/identity/start`;
+ const first = await f.request("POST", path, {}, f.jwt);
+ expect(first, JSON.stringify(first)).toMatchObject({ status: 200, body: { interactionId: expect.any(String) } });
+ const retry = await f.request("POST", path, {}, f.jwt);
+ expect(retry.body.interactionId).toBe(first.body.interactionId);
+ const [intent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, first.body.interactionId));
+ const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
+ expect(intent).toMatchObject({ addresseeUserId: f.userId, payload: { savedSetup: { endpointId: created.body.id, connectionId: created.body.connectionId } } });
+ expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
+ // Provider fixture marks the exact saved endpoint authenticated, after the run yields.
+ await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
+ await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, created.body.id));
+ await server.db.update(toolConnections).set({ status: "active", enabled: true, healthStatus: "healthy" }).where(eq(toolConnections.id, created.body.connectionId));
+ expect((await findSatisfiedToolConnection(server.db, task, intent))?.id).toBe(created.body.connectionId);
+ expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, intent)).toBeNull();
+ await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
+ expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull();
+ await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active");
+ await satisfiedConnectionIntentService(server.db).sweepPending();
+ const [delivery] = await server.db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, intent.id));
+ expect(delivery).toBeDefined();
+ });
+
+ it("retains a restricted user's personal self-service without company management or spoofed ownership", async () => {
+ const f = await fixture();
+ await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalType, "user"), eq(principalPermissionGrants.principalId, f.userId)));
+ provider = async url => {
+ if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] });
+ throw new Error("Unexpected AI fixture request");
+ };
+ const payload = { provider: "openai", method: "api_key", name: "Personal", ownership: "personal", apiKey: "personal-fixture", agentIds: [], allAgents: false };
+ try {
+ expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { ...payload, ownership: "shared" })).toMatchObject({ status: 403 });
+ expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { ...payload, responsibleUserId: randomUUID() })).toMatchObject({ status: 403 });
+ expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, payload)).toMatchObject({ status: 201 });
+ } finally { provider = null; }
+ });
+
+ it("rejects foreign personal metadata, missing responsible users and task-bridge keys", async () => {
+ const f = await fixture();
+ const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Foreign personal fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
+ expect(created.status).toBe(201);
+ await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, created.body.id));
+ expect(await f.request("GET", `/tool-connections/${created.body.id}`)).toMatchObject({ status: 403 });
+ const listed = await f.request("GET", `/companies/${f.companyId}/tools/connections`);
+ expect(JSON.stringify(listed.body)).not.toContain("Foreign personal fixture");
+ const [policy] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Private account rule", policyType: "block", selectors: { connectionIds: [created.body.id] } }).returning();
+ expect(JSON.stringify((await f.request("GET", `/companies/${f.companyId}/tools/policies`)).body)).not.toContain(policy.id);
+ expect(await f.request("POST", `/companies/${f.companyId}/tools/policies`, { name: "Cannot edit foreign access", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: [created.body.id] } })).toMatchObject({ status: 403 });
+
+ await server.db.update(agentApiKeys).set({ responsibleUserId: null }).where(eq(agentApiKeys.id, f.key.id));
+ expect((await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400);
+ const g = await fixture();
+ await server.db.update(agentApiKeys).set({ scopeConfig: { kind: "task_bridge", issueId: g.issueId } }).where(eq(agentApiKeys.id, g.key.id));
+ expect((await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400);
+ });
+
+ it("revalidates pending credential promotion after yielding without reviving cancelled work", async () => {
+ const f = await fixture();
+ const context = await connectionManagementService(server.db).authorize({ type: "agent", companyId: f.companyId,
+ agentId: f.agentId, runId: f.runId, onBehalfOfUserId: f.userId }, "connections:configure",
+ { type: "connection_management", companyId: f.companyId, purpose: "ai", operation: "login" });
+ const continuation = continuingConnectionManagement(context)!;
+ await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
+ await expect(context.reauthorize()).rejects.toThrow();
+ await expect(continuation.reauthorize()).resolves.toBeUndefined();
+ await server.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
+ await expect(continuation.reauthorize()).rejects.toThrow();
+ });
+
+ it("denies revoked agent grants and user authority, including shadow mode, without backfilling", async () => {
+ const f = await fixture();
+ await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect(await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).toMatchObject({ status: 403 });
+ const g = await fixture();
+ await accessService(server.db).ensureMembership(g.companyId, "user", g.userId, "operator", "suspended");
+ expect(await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).toMatchObject({ status: 403 });
+ });
+
+ it("enforces grant scopes, company isolation, effective trust, and Ask/Plan work modes", async () => {
+ const f = await fixture();
+ expect(await f.request("GET", `/companies/${f.foreignCompanyId}/tools/gallery`)).toMatchObject({ status: 403 });
+ const owned = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Scoped fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
+ expect(owned.status).toBe(201);
+ await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [owned.body.id], purposes: ["tool"], operations: ["GET /tool-connections/:connectionId"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect(await f.request("GET", `/tool-connections/${owned.body.id}`)).toMatchObject({ status: 200 });
+ expect(await f.request("PATCH", `/tool-connections/${owned.body.id}`, { name: "Beyond operation scope" })).toMatchObject({ status: 403 });
+ await server.db.update(principalPermissionGrants).set({ scope: { provider: "slack" } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
+ expect(await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId })).toMatchObject({ status: 403 });
+ const g = await fixture();
+ for (const workMode of ["ask", "planning", "skill_test"]) {
+ await server.db.update(issues).set({ workMode }).where(eq(issues.id, g.issueId));
+ expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden" }, g.jwt)).toMatchObject({ status: 403 });
+ }
+ await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, g.issueId));
+ await server.db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustPreset: "low_trust_review", lowTrustBoundary: { projectIds: [g.projectId] } } } }).where(eq(projects.id, g.projectId));
+ expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden trust" }, g.jwt)).toMatchObject({ status: 403 });
+ await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", projectIds: [g.projectId], rootIssueId: g.issueId } } } }).where(eq(agents.id, g.agentId));
+ expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden agent trust" })).toMatchObject({ status: 403 });
+ });
+});
diff --git a/server/src/__tests__/decision-models.test.ts b/server/src/__tests__/decision-models.test.ts
index 6c593912087..02f3d0b69bf 100644
--- a/server/src/__tests__/decision-models.test.ts
+++ b/server/src/__tests__/decision-models.test.ts
@@ -300,10 +300,12 @@ describe("company decision service", () => {
const url = `/api/companies/${f.companyId}/decision-model`;
expect((await request(app).get(url)).body.canManage).toBe(true);
expect((await request(app).get(`/api/companies/${other.companyId}/decision-model`)).status).toBe(403);
+ await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice")));
actor = { type: "board", userId: "alice", source: "session", companyIds: [f.companyId], memberships: [{ companyId: f.companyId, status: "active", membershipRole: "viewer" }] };
expect((await request(app).get(url)).body).toEqual({ canManage: false, settings: null, choices: [] });
expect((await request(app).put(url).send(f.config)).status).toBe(403);
expect((await request(app).post(`${url}/test`).send({ actor: { type: "system" } })).status).toBe(403);
+ await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice")));
actor = f.context.actor;
await f.service.configure(f.companyId, "alice", { ...f.config, enabled: false });
expect((await request(app).post(`${url}/test`).send({ state: "untrusted", actor: { type: "system" }, model: "untrusted" })).body).toEqual({ status: "unavailable", reason: "disabled" });
diff --git a/server/src/__tests__/email-address-check.test.ts b/server/src/__tests__/email-address-check.test.ts
index 08a13e0e682..3716ffbfd8d 100644
--- a/server/src/__tests__/email-address-check.test.ts
+++ b/server/src/__tests__/email-address-check.test.ts
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
-import type { Db } from "@paperclipai/db";
+import { companyMemberships, instanceUserRoles, principalPermissionGrants, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { forbidden } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
@@ -18,7 +18,16 @@ function app(actor = { type: "board", source: "local_implicit", userId: "board"
const instance = express();
instance.use(express.json());
instance.use((req, _res, next) => { req.actor = actor; next(); });
- instance.use("/api", emailRoutes({} as Db, {} as EmailChannelService));
+ // Provider calls remain stubbed, while the shared guard resolves real-shaped
+ // resource and membership rows before those calls.
+ const db = { select: () => ({ from: (table: unknown) => {
+ const rows = table === toolConnections ? [{ id: connectionId, companyId, createdByUserId: "board", connectionPurpose: "tool", credentialPolicy: "shared", config: { provider: "agentmail" }, transportConfig: {} }]
+ : table === companyMemberships ? [{ companyId, principalType: "user", principalId: "member", status: "active", membershipRole: "member" }]
+ : [];
+ expect([toolConnections, toolConnectionInstalls, companyMemberships, instanceUserRoles, principalPermissionGrants]).toContain(table);
+ return { where: () => Object.assign(Promise.resolve(rows), { limit: async () => rows }) };
+ } }) } as unknown as Db;
+ instance.use("/api", emailRoutes(db, {} as EmailChannelService));
instance.use(errorHandler);
return instance;
}
@@ -59,7 +68,7 @@ describe("AgentMail address checks", () => {
expect(mocks.credential).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
- it("does not allow agents to check addresses", async () => {
+ it("rejects agents without a responsible user", async () => {
await request(app({ type: "agent", agentId: connectionId, companyId })).post(path).send(input).expect(403);
expect(fetch).not.toHaveBeenCalled();
});
diff --git a/server/src/__tests__/email-routes-provider-errors.test.ts b/server/src/__tests__/email-routes-provider-errors.test.ts
index 742c0058ad7..a7bb01c77ce 100644
--- a/server/src/__tests__/email-routes-provider-errors.test.ts
+++ b/server/src/__tests__/email-routes-provider-errors.test.ts
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
-import type { Db } from "@paperclipai/db";
+import { agents, type Db } from "@paperclipai/db";
import { describe, expect, it, vi } from "vitest";
import { forbidden } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
@@ -26,7 +26,13 @@ function app(setup: () => Promise) {
req.actor = { type: "board", source: "local_implicit", userId: "board" };
next();
});
- instance.use("/api", emailRoutes({} as Db, { setup, inspect: setup } as unknown as EmailChannelService));
+ // This unit suite exercises provider error translation. The shared guard
+ // also resolves the setup's target agent before calling the stubbed service.
+ const db = { select: () => ({ from: (table: unknown) => {
+ expect(table).toBe(agents);
+ return { where: async () => [{ id: agentId, companyId }] };
+ } }) } as unknown as Db;
+ instance.use("/api", emailRoutes(db, { setup, inspect: setup } as unknown as EmailChannelService));
instance.use(errorHandler);
return instance;
}
diff --git a/server/src/__tests__/helpers/runner-api-server.ts b/server/src/__tests__/helpers/runner-api-server.ts
index b85fa611d54..b4bfe9f651b 100644
--- a/server/src/__tests__/helpers/runner-api-server.ts
+++ b/server/src/__tests__/helpers/runner-api-server.ts
@@ -1,10 +1,13 @@
+import { hashPassword } from "better-auth/crypto";
+import { createBetterAuthInstance, createBetterAuthHandler, resolveBetterAuthSession } from "../../auth/better-auth.js";
+import type { Config } from "../../config.js";
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, sql } from "drizzle-orm";
-import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
+import { agents, authUsers, authAccounts, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
import { documentService } from "../../services/documents.js";
import { connectionIntentService } from "../../services/connection-intents.js";
import { initializeRunIdentity } from "../../services/run-identity.js";
@@ -21,15 +24,25 @@ const CONNECTION_SCENARIOS: readonly RunnerConnectionScenario[] = ["fresh", "pen
/** Disposable real routes, database and storage. A fresh company isolates each attempt. */
export async function startRunnerApiTestServer(options: {
deploymentMode?: "authenticated" | "local_trusted";
+ authPublicBaseUrl?: string;
+ humanSessions?: boolean;
} = {}) {
const root = await mkdtemp(join(tmpdir(), "paperclip-api-eval-"));
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-");
const db = createDb(temporary.connectionString);
const storage = createStorageService(createLocalDiskStorageProvider(join(root, "storage")));
+ const origin = options.authPublicBaseUrl ?? "http://127.0.0.1";
+ const auth = options.humanSessions ? createBetterAuthInstance(db, {
+ deploymentMode: "authenticated", deploymentExposure: "private", authBaseUrlMode: "explicit",
+ authPublicBaseUrl: origin, authDisableSignUp: true,
+ } as Config, [origin]) : undefined;
+ const humanCookies = new Map();
const app = await createApp(db, {
uiMode: "none", serverPort: 0, storageService: storage,
deploymentMode: options.deploymentMode ?? "authenticated", deploymentExposure: "private",
allowedHostnames: ["127.0.0.1"], bindHost: "127.0.0.1", authReady: true,
+ authPublicBaseUrl: options.authPublicBaseUrl, chatWebhookPublicBaseUrl: options.authPublicBaseUrl,
+ ...(auth ? { betterAuthHandler: createBetterAuthHandler(auth), resolveSession: (req: import("express").Request) => resolveBetterAuthSession(auth, req) } : {}),
companyDeletionEnabled: false, instanceId: `eval-${randomUUID()}`,
localPluginDir: join(root, "plugins"), managedPluginAutoInstall: [],
decisionServiceOptions: { wakeOriginAgent: async () => undefined },
@@ -44,6 +57,21 @@ export async function startRunnerApiTestServer(options: {
setupRunnerPrpWebSocketServer(http, { apiUrl });
return {
db, root, apiUrl, storage,
+ async humanSession(userId: string) {
+ if (!auth) throw new Error("Enable humanSessions for this fixture");
+ if (humanCookies.has(userId)) return humanCookies.get(userId)!;
+ const [user] = await db.select().from(authUsers).where(eq(authUsers.id, userId));
+ const password = `fixture-${randomUUID()}`;
+ await db.insert(authAccounts).values({ id: randomUUID(), issuer: "local:credential", accountId: userId,
+ providerId: "credential", userId, password: await hashPassword(password), createdAt: new Date(), updatedAt: new Date() });
+ const response = await auth.handler(new Request(`${origin}/api/auth/sign-in/email`, {
+ method: "POST", headers: { "Content-Type": "application/json", origin }, body: JSON.stringify({ email: user.email, password }),
+ }));
+ if (!response.ok) throw new Error(`Fixture sign-in failed (${response.status})`);
+ const cookie = response.headers.getSetCookie().map(cookie => cookie.split(";")[0]).join("; ");
+ humanCookies.set(userId, cookie);
+ return cookie;
+ },
async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean; conversation?: boolean; connectionScenario?: RunnerConnectionScenario; contextSnapshot?: Record; disableWakeOnDemand?: boolean } = {}) {
if (options.connectionScenario !== undefined && !CONNECTION_SCENARIOS.includes(options.connectionScenario)) throw new Error(`Unknown connection eval scenario: ${String(options.connectionScenario)}`);
// This DB is created inside this helper, never supplied by a caller. Paid
diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts
index ff43e560394..b67d31593ca 100644
--- a/server/src/__tests__/openapi-routes.test.ts
+++ b/server/src/__tests__/openapi-routes.test.ts
@@ -407,13 +407,13 @@ describe("openapi routes", () => {
expect(operation.responses["503"]).toBeDefined();
});
- it("documents board-only pool management with revision-checked deletion", () => {
+ it("documents delegated pool management with revision-checked deletion", () => {
const { spec } = loadSpecRoutes();
const pools = spec.paths["/api/companies/{companyId}/ai-connection-pools"];
const remove = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}"].delete;
const inspection = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection"].get;
for (const operation of [pools.get, pools.post, remove, inspection]) {
- expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
+ expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }, { AgentBearerAuth: [] }]);
}
expect(remove.requestBody.required).toBe(true);
expect(remove.requestBody.content["application/json"].schema).toMatchObject({
@@ -616,7 +616,7 @@ describe("openapi routes", () => {
).not.toContain("sessionToken");
});
- it("publishes the complete board contract for chat channels", () => {
+ it("publishes delegated chat configuration and human-only confirmation boundaries", () => {
const { spec } = loadSpecRoutes();
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
const operations = [
@@ -678,6 +678,13 @@ describe("openapi routes", () => {
],
] as const;
+ const humanOnly = new Set([
+ "/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
+ "/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
+ "/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
+ "/api/chat-endpoints/{endpointId}/github/identity", "/api/chat-identity-links/preview", "/api/chat-identity-links/confirm",
+ "/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve", "/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve",
+ ]);
for (const [method, routePath] of operations) {
const operation = spec.paths[routePath]?.[method];
expect(
@@ -686,10 +693,10 @@ describe("openapi routes", () => {
).toBeDefined();
expect(
operation.security,
- `${method.toUpperCase()} ${routePath} is board-only`,
- ).toEqual(boardSecurity);
+ `${method.toUpperCase()} ${routePath} retains its actor boundary`,
+ ).toEqual(humanOnly.has(routePath) ? boardSecurity : [...boardSecurity, { AgentBearerAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({
- actor: "board",
+ actor: humanOnly.has(routePath) ? "board" : "board_or_agent",
});
expect(operation.tags).toContain("chat-channels");
}
@@ -1074,12 +1081,12 @@ describe("openapi routes", () => {
expect(replacement.responses["422"]).toBeDefined();
});
- it("documents the manager-only GitHub wizard and compatible owner-aware registration", () => {
+ it("documents delegated GitHub setup with human identity confirmation and compatible owner-aware registration", () => {
const { spec } = loadSpecRoutes();
for (const [suffix, method] of [["draft", "put"], ["setup", "post"], ["identity/start", "post"], ["identity/confirm", "post"]]) {
const operation = spec.paths[`/api/chat-endpoints/{endpointId}/github/${suffix}`][method];
- expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
- expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
+ expect(operation["x-paperclip-authorization"]).toEqual({ actor: suffix === "identity/confirm" ? "board" : "board_or_agent" });
+ expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }, ...(suffix === "identity/confirm" ? [] : [{ AgentBearerAuth: [] }])]);
}
const registration = spec.paths["/api/chat-endpoints/{endpointId}/github/registration"].post;
const input = registration.requestBody.content["application/json"].schema;
@@ -1095,7 +1102,7 @@ describe("openapi routes", () => {
const setup = spec.paths[root + "setup"].post.responses["200"].content["application/json"].schema;
expect(setup.properties.restartableRegistrationId.format).toBe("uuid");
const restart = spec.paths[root + "registration/restart"].post;
- expect(restart["x-paperclip-authorization"]).toEqual({ actor: "board" });
+ expect(restart["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
const restartInput = restart.requestBody.content["application/json"].schema;
expect(restartInput.required).toEqual(expect.arrayContaining(["registrationId", "appNotCreated"]));
expect(restartInput.properties.appNotCreated.enum ?? [restartInput.properties.appNotCreated.const]).toEqual([true]);
@@ -1109,7 +1116,7 @@ describe("openapi routes", () => {
const page = spec.paths[root + "repositories"].get;
expect(page.parameters.map((param: { name: string }) => param.name)).toEqual(expect.arrayContaining(["endpointId", "offset", "limit", "search"]));
expect(page.responses["200"].content["application/json"].schema.properties).toHaveProperty("totalCount");
- expect(spec.paths[root + "repositories/access"].put["x-paperclip-authorization"]).toEqual({ actor: "board" });
+ expect(spec.paths[root + "repositories/access"].put["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
const review = spec.paths[root + "reviews/{reviewId}"].get;
expect(review.parameters.map((param: { name: string }) => param.name)).toEqual(["endpointId", "reviewId"]);
expect(review.responses["404"]).toBeDefined();
diff --git a/server/src/__tests__/task-connector-read-routes.test.ts b/server/src/__tests__/task-connector-read-routes.test.ts
index f903e4693c7..74fdceb855a 100644
--- a/server/src/__tests__/task-connector-read-routes.test.ts
+++ b/server/src/__tests__/task-connector-read-routes.test.ts
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
-import type { Db } from "@paperclipai/db";
+import { companyMemberships, instanceUserRoles, issues, type Db } from "@paperclipai/db";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/error-handler.js";
import { chatChannelRoutes } from "../routes/chat-channels.js";
@@ -25,11 +25,21 @@ function app(actor = boardActor) {
req.actor = actor;
next();
});
- instance.use("/api", chatChannelRoutes({} as Db, {
+ // Supply the resource and active membership that the management read guard
+ // now resolves. Connector service behavior stays isolated in this unit suite.
+ const db = { select: () => ({ from: (table: unknown) => {
+ const rows = table === issues ? [{ id: issueId, companyId, projectId: null }]
+ : table === companyMemberships ? [{ companyId, principalType: "user", principalId: boardActor.userId, membershipRole: "operator", status: "active" }]
+ : table === instanceUserRoles ? []
+ : null;
+ if (!rows) throw new Error("Unexpected resource query in task read fixture");
+ return { where: () => Object.assign(Promise.resolve(rows), { limit: async () => rows }) };
+ } }) } as unknown as Db;
+ instance.use("/api", chatChannelRoutes(db, {
service: chat as unknown as ChatChannelService,
heartbeat: { wakeup: vi.fn() },
}));
- instance.use("/api", emailRoutes({} as Db, email as unknown as EmailChannelService));
+ instance.use("/api", emailRoutes(db, email as unknown as EmailChannelService));
instance.use(errorHandler);
return instance;
}
@@ -69,7 +79,7 @@ describe("task connector read routes", () => {
it.each([issueId, `chat:${issueId}`])("retains authentication checks for %s", async (id) => {
const server = app({ type: "none", source: "none" });
- await request(server).get(`/api/issues/${id}/chat-binding`).expect(403);
+ await request(server).get(`/api/issues/${id}/chat-binding`).expect(401);
await request(server).get(`/api/companies/${companyId}/email/tasks/${id}`).expect(401);
expect(chat.getIssueBinding).not.toHaveBeenCalled();
expect(email.authorizeRead).not.toHaveBeenCalled();
diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts
index 38c49754c4b..6caafb2cb03 100644
--- a/server/src/__tests__/tool-access-service.test.ts
+++ b/server/src/__tests__/tool-access-service.test.ts
@@ -392,7 +392,15 @@ function createRouteApp(
) {
const app = express();
app.use(express.json());
- app.use((req, _res, next) => {
+ app.use(async (req, _res, next) => {
+ // Session fixtures must persist the membership normally loaded by authentication.
+ // Keep existing rows unchanged so explicit restrictions and revocations survive.
+ if (actor?.type === "board" && actor.userId) {
+ for (const membership of actor.memberships ?? []) await db.insert(companyMemberships).values({
+ companyId: membership.companyId, principalType: "user", principalId: actor.userId,
+ membershipRole: membership.membershipRole, status: membership.status,
+ }).onConflictDoNothing();
+ }
req.actor = actor ?? {
type: "board",
userId: "board-user",
@@ -1744,7 +1752,7 @@ describeEmbeddedPostgres("tool access service", () => {
).post(`/api/tool-connections/${connection.id}/health-check`);
expect(response.status).toBe(403);
- expect(response.body.error).toContain("need access to this connection");
+ expect(response.body.error).toContain("Missing permission: tools:manage_connections");
});
it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => {
@@ -4849,7 +4857,7 @@ describeEmbeddedPostgres("tool access service", () => {
});
});
- it("keeps duplicate, delete, and new-tools profile routes board-only and viewer-safe", async () => {
+ it("keeps duplicate, delete, and new-tools profile routes permission-gated and viewer-safe", async () => {
const company = await createCompany(db);
const [agent] = await db
.insert(agents)
@@ -20177,7 +20185,7 @@ describeEmbeddedPostgres("tool access service", () => {
.send({ name: "Member edit" });
expect(denied.status).toBe(403);
expect(denied.body.error).toContain(
- "connection creator or a connection manager",
+ "Missing permission: tools:manage_connections",
);
await request(createRouteApp(db, creator))
diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts
index 32dcd49bef7..0fddfec1e01 100644
--- a/server/src/__tests__/tool-gateway.test.ts
+++ b/server/src/__tests__/tool-gateway.test.ts
@@ -4987,7 +4987,7 @@ rl.on("line", (line) => {
}
});
- it("denies agent actors from runtime control and raw gateway audit routes", async () => {
+ it("denies agents without responsible-user authority from runtime control and gateway audit routes", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
@@ -5004,25 +5004,25 @@ rl.on("line", (line) => {
.get("/api/tool-gateway/runtime-slots")
.query({ companyId: company.id });
expect(list.status).toBe(403);
- expect(list.body.error).toBe("Board access required");
+
const stop = await request(app)
.post("/api/tool-gateway/runtime-slots/slot-1/stop")
.send({ companyId: company.id });
expect(stop.status).toBe(403);
- expect(stop.body.error).toBe("Board access required");
+
const restart = await request(app)
.post("/api/tool-gateway/runtime-slots/slot-1/restart")
.send({ companyId: company.id });
expect(restart.status).toBe(403);
- expect(restart.body.error).toBe("Board access required");
+
const audit = await request(app)
.get("/api/tool-gateway/audit")
.query({ companyId: company.id });
expect(audit.status).toBe(403);
- expect(audit.body.error).toBe("Board access required");
+
});
it("allows board runtime control and audit reads through explicit board permissions", async () => {
diff --git a/server/src/app.ts b/server/src/app.ts
index c16625b8f0e..d7d0e4ee9ca 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -704,16 +704,16 @@ export async function createApp(
app.get("/api/chat-github/manifest/callback", async (req, res) => {
res.set("Cache-Control", "no-store");
res.set("Referrer-Policy", "no-referrer");
- const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? ""));
+ const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor);
res.redirect(303, redirect);
});
app.get("/api/chat-github/cloud/callback", async (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
- res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined));
+ res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined, req.actor));
});
app.get("/api/chat-github/identity/callback", async (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
- res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? "")));
+ res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor));
});
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
const bundledCatalogRoot =
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 7351ab8fb20..625bd20a4a2 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -1,5 +1,7 @@
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
+import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
+import { connectionManagementGuard, connectionRequestContext, connectionRequestUserId, aiLoginUserAction } from "./connection-management-authz.js";
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, normalizeClaudeRunnerOptions, type AgentConfigurationWarning, type AgentRunnerChoice } from "@paperclipai/shared";
import { resolveNewAgentRunnerForCompany } from "../services/agent-runner-selection.js";
@@ -584,6 +586,7 @@ export function agentRoutes(
const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
const router = Router();
+ const managementGuard = connectionManagementGuard(db, "ai");
const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const svcLifecycle = createAgentLifecycle(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const access = accessService(db);
@@ -887,7 +890,7 @@ export function agentRoutes(
if (managedSession?.aiConnection) {
await adapterLoginStore.withCompanyAdapterPromotionLock(context.companyId, context.startedByUserId, context.adapterType, async () => {
if (!(await checkStagedCredentialReadiness(authBytes)).ready) throw new Error("Provider credential is not ready");
- await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), { id: context.sessionId });
+ await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), { id: context.sessionId }, undefined, context.managementContext);
});
return;
}
@@ -1132,7 +1135,7 @@ export function agentRoutes(
if (managedSession?.aiConnection) {
await adapterLoginStore.withCompanyAdapterPromotionLock(context.companyId, context.startedByUserId, context.adapterType, async () => {
if (!(await checkStagedGrokCredentialReadiness(authBytes)).ready) throw new Error("Provider credential is not ready");
- await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), { id: context.sessionId });
+ await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), { id: context.sessionId }, undefined, context.managementContext);
});
return;
}
@@ -1421,6 +1424,12 @@ export function agentRoutes(
}
async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) {
+ if (req.actor.type === "agent") {
+ const context = connectionRequestContext(req);
+ if (!context || context.resource.companyId !== companyId || !context.responsibleUserId) throw forbidden("Connection management authority is required");
+ await context.reauthorize();
+ return context.responsibleUserId;
+ }
assertBoard(req);
assertCompanyAccess(req, companyId);
const decision = await access.decide({
@@ -3048,7 +3057,7 @@ export function agentRoutes(
const type = assertKnownAdapterType(req.params.type as string);
const poolId = asNonEmptyString(req.query.poolId);
if (poolId) {
- const pool = (await aiConnectionRouterService(db).selectable(companyId, getActorInfo(req).actorId)).find(pool => pool.id === poolId);
+ const pool = (await aiConnectionRouterService(db).selectable(companyId, responsibleUserForAiRequest(req) ?? "")).find(pool => pool.id === poolId);
if (!pool) throw unprocessable("Enable an accessible AI connection pool");
const catalog = new Map();
for (const member of pool.members) {
@@ -3591,7 +3600,7 @@ export function agentRoutes(
// helper requires a board actor with the configuration permission, and it
// returns the immutable owner identifier that the service persists on the row.
router.post(
- "/companies/:companyId/adapters/:type/login-sessions",
+ "/companies/:companyId/adapters/:type/login-sessions", managementGuard("connections:configure", "POST /companies/:companyId/adapters/:type/login-sessions", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
@@ -3623,6 +3632,7 @@ export function agentRoutes(
if (!resolved) return;
const { ownerUserId: startedByUserId, data } = resolved;
+ if (req.actor.type === "agent" && !data.aiConnection) throw forbidden("Agent logins must save a managed AI account");
if (data.aiConnection) {
await assertAiConnectionCreateAccess(db, req, companyId, data.aiConnection);
if (!isAiConnectionCompatible(data.aiConnection, type)) throw unprocessable("Incompatible login method");
@@ -3637,6 +3647,7 @@ export function agentRoutes(
startedByUserId,
ttlSeconds: data.ttlSeconds,
aiConnection: data.aiConnection,
+ managementContext: continuingConnectionManagement(connectionRequestContext(req)),
signal: controller.signal,
});
} catch (error) {
@@ -3659,7 +3670,7 @@ export function agentRoutes(
adapterLoginAbortControllers.delete(startedSessionId);
});
- res.status(201).json(result.session);
+ res.status(201).json({ ...result.session, ...await aiLoginUserAction(db, req, companyId, result.session.sessionId) });
},
);
@@ -3671,7 +3682,7 @@ export function agentRoutes(
// This route registers before the `:sessionId` route below, so Express
// never matches the literal `active` segment as a session id.
router.get(
- "/companies/:companyId/adapters/:type/login-sessions/active",
+ "/companies/:companyId/adapters/:type/login-sessions/active", managementGuard("connections:read", "GET /companies/:companyId/adapters/:type/login-sessions/active", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
@@ -3695,7 +3706,7 @@ export function agentRoutes(
// active-session route above: a shared or a browser cache never stores the
// authenticated response between one poll and the next.
router.get(
- "/companies/:companyId/adapters/:type/login-sessions/:sessionId",
+ "/companies/:companyId/adapters/:type/login-sessions/:sessionId", managementGuard("connections:read", "GET /companies/:companyId/adapters/:type/login-sessions/:sessionId", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
@@ -3716,7 +3727,7 @@ export function agentRoutes(
// Cancel a login session. The owner aborts the in-flight run. A non-owner or a
// cross-company caller receives a 404.
router.post(
- "/companies/:companyId/adapters/:type/login-sessions/:sessionId/cancel",
+ "/companies/:companyId/adapters/:type/login-sessions/:sessionId/cancel", managementGuard("connections:configure", "POST /companies/:companyId/adapters/:type/login-sessions/:sessionId/cancel", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
@@ -5600,6 +5611,13 @@ export function agentRoutes(
if (nextAiBinding) {
await assertCanConfigureAgentSetup(req, existing);
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
+ if (changed && req.actor.type === "agent") {
+ await connectionManagementService(db).authorize(req.actor, "connections:profiles", {
+ type: "connection_management", companyId: existing.companyId, purpose: "ai", targetAgentId: existing.id,
+ connectionId: "connectionId" in nextAiBinding ? nextAiBinding.connectionId : undefined,
+ operation: "agent.ai-binding.update",
+ });
+ }
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record;
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
@@ -6445,11 +6463,10 @@ export function agentRoutes(
* from the authenticated actor.
*/
const deriveSetupTokenOwnerUserId = (req: Request): string => {
- const actor = getActorInfo(req);
- if (actor.actorType !== "user") {
- throw forbidden("A user must own a setup-token login session.");
- }
- return actor.actorId;
+ const userId = connectionRequestUserId(req);
+ if (req.actor.type === "agent" && !connectionRequestContext(req)) throw forbidden("Connection management authority is required");
+ if (!userId) throw forbidden("A user must own a setup-token login session.");
+ return userId;
};
/**
@@ -6615,7 +6632,13 @@ export function agentRoutes(
res.json(body);
});
- router.post("/companies/:companyId/setup-token-login-sessions", async (req, res) => {
+ router.post("/companies/:companyId/setup-token-login-sessions", (req, res, next) => {
+ if (!startClaudeSetupTokenSessionRequestSchema.safeParse(req.body).success) {
+ res.status(400).json({ error: "The Claude login start request is invalid." });
+ return;
+ }
+ next();
+ }, managementGuard("connections:configure", "POST /companies/:companyId/setup-token-login-sessions", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
// The shared start-route spine derives the owner, validates the strict
@@ -6681,6 +6704,7 @@ export function agentRoutes(
if (!resolved) return;
const { ownerUserId, data } = resolved;
const { environmentId, adapterType } = data;
+ if (req.actor.type === "agent" && !data.aiConnection) throw forbidden("Agent logins must save a managed AI account");
if (data.aiConnection) {
await assertAiConnectionCreateAccess(db, req, companyId, data.aiConnection);
if (!isAiConnectionCompatible(data.aiConnection, adapterType)) throw unprocessable("Incompatible login method");
@@ -6694,6 +6718,7 @@ export function agentRoutes(
environmentId,
confirmedOverwrite,
aiConnection: data.aiConnection,
+ managementContext: continuingConnectionManagement(connectionRequestContext(req)),
};
// Read the panel mode from the adapter capability. The guard already checked
// the capability, so it is present here. The client renders the panel from
@@ -6712,7 +6737,7 @@ export function agentRoutes(
panelMode,
prompt: null,
};
- res.status(201).json(body);
+ res.status(201).json({ ...body, ...await aiLoginUserAction(db, req, companyId, started.sessionId) });
} catch (err) {
sendSetupTokenError(res, err);
}
@@ -6726,7 +6751,7 @@ export function agentRoutes(
//
// This route registers before the `:sessionId` route below, so Express never
// matches the literal `active` segment as a session id.
- router.get("/companies/:companyId/setup-token-login-sessions/active", async (req, res) => {
+ router.get("/companies/:companyId/setup-token-login-sessions/active", managementGuard("connections:read", "GET /companies/:companyId/setup-token-login-sessions/active", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
if (ownerUserId === null) return;
@@ -6753,7 +6778,7 @@ export function agentRoutes(
res.json({ ...body, ...(descriptor.aiConnection ? { aiConnection: descriptor.aiConnection } : {}) });
});
- router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", async (req, res) => {
+ router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", managementGuard("connections:read", "GET /companies/:companyId/setup-token-login-sessions/:sessionId", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
if (ownerUserId === null) return;
@@ -6772,7 +6797,7 @@ export function agentRoutes(
}
});
- router.get("/companies/:companyId/setup-token-login-sessions/:sessionId/prompt", async (req, res) => {
+ router.get("/companies/:companyId/setup-token-login-sessions/:sessionId/prompt", managementGuard("connections:read", "GET /companies/:companyId/setup-token-login-sessions/:sessionId/prompt", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
if (ownerUserId === null) return;
@@ -6806,6 +6831,7 @@ export function agentRoutes(
});
router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/code", async (req, res) => {
+ assertBoard(req);
const companyId = req.params.companyId as string;
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
if (ownerUserId === null) return;
@@ -6841,7 +6867,7 @@ export function agentRoutes(
}
});
- router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/completion", async (req, res) => {
+ router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/completion", managementGuard("connections:configure", "POST /companies/:companyId/setup-token-login-sessions/:sessionId/completion", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
if (ownerUserId === null) return;
@@ -6863,7 +6889,7 @@ export function agentRoutes(
}
});
- router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/cancel", async (req, res) => {
+ router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/cancel", managementGuard("connections:configure", "POST /companies/:companyId/setup-token-login-sessions/:sessionId/cancel", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
if (ownerUserId === null) return;
diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts
index 639fc6b2320..71a39acf597 100644
--- a/server/src/routes/ai-connections.ts
+++ b/server/src/routes/ai-connections.ts
@@ -1,3 +1,5 @@
+import { connectionManagementGuard, connectionRequestActor, connectionRequestContext, connectionRequestUserId, aiLoginUserAction, filterConnectionManagementRows } from "./connection-management-authz.js";
+import { connectionManagementService } from "../services/connection-management.js";
import { validateAiApiKey } from "../services/ai-api-key-test.js";
export { validateAiApiKey } from "../services/ai-api-key-test.js";
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
@@ -26,7 +28,7 @@ import {
type AiConnectionList,
aiConnectionPoolConfigSchema,
} from "@paperclipai/shared";
-import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
+import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { accessService } from "../services/access.js";
import { logActivity } from "../services/activity-log.js";
@@ -44,11 +46,9 @@ export function responsibleUserForAiRequest(req: Request): string | null {
}
export async function canManageAiConnections(db: Db, req: Request, companyId: string): Promise {
- const membership = req.actor.memberships?.find((m) => m.companyId === companyId && m.status === "active");
- if (membership?.membershipRole === "viewer") return false;
- return req.actor.source === "local_implicit" || Boolean(req.actor.isInstanceAdmin)
- || membership?.membershipRole === "owner" || membership?.membershipRole === "admin"
- || await accessService(db).hasPermission(companyId, "user", getActorInfo(req).actorId, "tools:manage_connections");
+ return (await connectionManagementService(db).capability(req.actor, "connections:configure", {
+ type: "connection_management", companyId, purpose: "ai", operation: "manage",
+ }, { mutation: false })).allowed;
}
export async function assertAiConnectionCreateAccess(
@@ -60,10 +60,11 @@ export async function assertAiConnectionCreateAccess(
"ownership" | "allAgents" | "agentIds" | "connectionId"
>,
) {
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const actor = getActorInfo(req);
- const userId = actor.actorId;
+ const userId = responsibleUserForAiRequest(req);
+ if (!userId) throw forbidden("A responsible user is required");
if (input.connectionId) {
const [grant] = await db
.select({
@@ -88,7 +89,7 @@ export async function assertAiConnectionCreateAccess(
"Only the account owner can reconnect this AI connection",
);
}
- const membership = req.actor.memberships?.find(
+ const membership = (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.find(
(m) => m.companyId === companyId && m.status === "active",
);
const manager = await canManageAiConnections(db, req, companyId);
@@ -105,7 +106,7 @@ export async function assertAiConnectionCreateAccess(
if (
!(
await accessService(db).decide({
- actor: { type: "board", userId },
+ actor: req.actor,
action: "agent_config:update",
resource: { type: "agent", companyId, agentId: id },
})
@@ -119,22 +120,27 @@ export async function assertAiConnectionCreateAccess(
return userId;
}
-/** Creating an agent may install a shared connection only with the existing
- * connection-configure authority. An agent actor cannot grant itself access. */
+/** Installation is a connection/profile change, bounded by the creator's existing authority. */
export async function canInstallSharedAiConnectionForNewAgent(
db: Db, req: Request, companyId: string, binding: AiConnectionBinding,
): Promise {
- if (req.actor.type !== "board" || binding.mode !== "shared") return false;
+ if (binding.mode !== "shared" || (req.actor.type === "agent" && (!req.actor.agentId || !req.actor.companyId))) return false;
assertCompanyAccess(req, companyId);
- const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active");
- if (member?.membershipRole === "viewer") return false;
- const userId = getActorInfo(req).actorId;
- const [connection] = await db.select({ creator: toolConnections.createdByUserId })
- .from(toolConnections).where(and(eq(toolConnections.companyId, companyId),
- eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai")));
- if (!connection) return false;
- return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true ||
- connection.creator === userId || await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections");
+ if (req.actor.type === "board") {
+ const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active");
+ if (member?.membershipRole === "viewer") return false;
+ const userId = getActorInfo(req).actorId;
+ const [connection] = await db.select({ creator: toolConnections.createdByUserId }).from(toolConnections).where(and(
+ eq(toolConnections.companyId, companyId), eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai"),
+ ));
+ return Boolean(connection && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin || connection.creator === userId ||
+ await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections")));
+ }
+ const resource = { type: "connection_management" as const, companyId, connectionId: binding.connectionId,
+ purpose: "ai" as const, operation: "ai_binding.install" };
+ const management = connectionManagementService(db);
+ return (await management.capability(req.actor, "connections:configure", resource)).allowed &&
+ (await management.capability(req.actor, "connections:profiles", resource)).allowed;
}
@@ -143,53 +149,49 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ router.get("/companies/:companyId/ai-connection-pools", managementGuard("connections:read", "GET /companies/:companyId/ai-connection-pools", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req); assertCompanyAccess(req, companyId);
+ assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
res.setHeader("Cache-Control", "no-store");
- res.json(await pools.list(companyId));
+ res.json(await filterConnectionManagementRows(db, req, "connections:read", await pools.list(companyId), pool => ({ connectionId: pool.id })));
});
- router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", async (req, res) => {
+ router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", managementGuard("connections:read", "GET /companies/:companyId/ai-connection-pools/:poolId/inspection", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req); assertCompanyAccess(req, companyId);
+ assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
res.setHeader("Cache-Control", "no-store");
- res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), getActorInfo(req).actorId));
+ res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), responsibleUserForAiRequest(req)!));
});
- router.post("/companies/:companyId/ai-connection-pools", async (req, res) => {
+ router.post("/companies/:companyId/ai-connection-pools", managementGuard("connections:configure", "POST /companies/:companyId/ai-connection-pools", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req); assertCompanyAccess(req, companyId);
+ assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
const body = z.object({ pluginKey: z.string().min(1).max(160), id: z.string().uuid().optional(), expectedRevision: z.number().int().positive().optional(), config: aiConnectionPoolConfigSchema }).strict().parse(req.body);
- res.json(await pools.save(body.pluginKey, { ...body, companyId }, getActorInfo(req).actorId));
+ res.json(await pools.save(body.pluginKey, { ...body, companyId }, responsibleUserForAiRequest(req)!, connectionRequestContext(req)));
});
- router.delete("/companies/:companyId/ai-connection-pools/:poolId", async (req, res) => {
+ router.delete("/companies/:companyId/ai-connection-pools/:poolId", managementGuard("connections:configure", "DELETE /companies/:companyId/ai-connection-pools/:poolId", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req); assertCompanyAccess(req, companyId);
+ assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
const poolId = z.string().uuid().parse(req.params.poolId);
const { expectedRevision } = z.object({ expectedRevision: z.number().int().positive() }).strict().parse(req.body);
- res.json(await pools.remove(companyId, poolId, expectedRevision, getActorInfo(req).actorId));
+ res.json(await pools.remove(companyId, poolId, expectedRevision, responsibleUserForAiRequest(req)!, connectionRequestContext(req)));
});
- function assertLocalOperator(req: Request) {
- assertBoard(req);
- assertCompanyAccess(req, req.params.companyId as string);
- if (req.actor.source !== "local_implicit")
- throw forbidden("Only the local operator can connect this machine's CLI account.");
- }
- router.post("/companies/:companyId/ai-connections/local/attempts", validate(localAiLoginStartSchema), async (req, res) => {
+ router.post("/companies/:companyId/ai-connections/local/attempts", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local/attempts", { personal: true }), validate(localAiLoginStartSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const { restart, ...intent } = localAiLoginStartSchema.parse(req.body);
assertLocalLoginAvailable();
const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent);
res.setHeader("Cache-Control", "no-store");
- res.status(201).json(await localLogin.start(companyId, userId, intent, restart));
+ const attempt = await localLogin.start(companyId, userId, intent, restart, connectionRequestContext(req));
+ res.status(201).json({ ...attempt, ...await aiLoginUserAction(db, req, companyId, attempt.sessionId) });
});
- router.post("/companies/:companyId/ai-connections/local/check", validate(localAiConnectionSchema), async (req, res) => {
+ router.post("/companies/:companyId/ai-connections/local/check", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local/check", { personal: true }), validate(localAiConnectionSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const { localSessionId, ...intent } = localAiConnectionSchema.parse(req.body);
assertLocalLoginAvailable();
@@ -197,11 +199,11 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
- assertBoard(req);
+ router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", managementGuard("connections:configure", "DELETE /companies/:companyId/ai-connections/local/attempts/:sessionId", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
assertCompanyAccess(req, req.params.companyId as string);
const id = z.string().uuid().parse(req.params.sessionId);
- await localLogin.cancel(req.params.companyId as string, getActorInfo(req).actorId, id);
+ await localLogin.cancel(req.params.companyId as string, responsibleUserForAiRequest(req)!, id, connectionRequestContext(req));
res.json({ ok: true });
});
router.post("/companies/:companyId/ai-connections/local/attempts/:sessionId/code", validate(z.object({ browserCode: browserCodeSchema })), async (req, res) => {
@@ -214,11 +216,11 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ router.get("/companies/:companyId/ai-connections", managementGuard("connections:read", "GET /companies/:companyId/ai-connections", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
- const currentUserId = getActorInfo(req).actorId;
+ const currentUserId = responsibleUserForAiRequest(req)!;
res.setHeader("Cache-Control", "no-store");
const agentId = req.query.agentId;
if (agentId !== undefined && !z.string().uuid().safeParse(agentId).success)
@@ -235,23 +237,23 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
const companyId = req.params.companyId as string;
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const connectionId = z.string().uuid().safeParse(req.params.connectionId);
const grantId = z.string().uuid().optional().safeParse(req.query.grantId);
if (!connectionId.success || !grantId.success) throw unprocessable("Invalid connection or grant ID");
res.setHeader("Cache-Control", "no-store");
- res.json(await service.probeUsage(companyId, getActorInfo(req).actorId, connectionId.data, grantId.data));
+ res.json(await service.probeUsage(companyId, responsibleUserForAiRequest(req)!, connectionId.data, grantId.data));
},
);
router.get(
- "/companies/:companyId/ai-connections/:connectionId/active-runs",
+ "/companies/:companyId/ai-connections/:connectionId/active-runs", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/:connectionId/active-runs", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (!z.string().uuid().safeParse(req.params.connectionId).success)
throw unprocessable("Invalid connection ID");
@@ -265,7 +267,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters account.id === connection.id))
+ if (!connection || !(await service.list(companyId, responsibleUserForAiRequest(req)!)).some(account => account.id === connection.id))
throw notFound("AI connection not found");
res.setHeader("Cache-Control", "no-store");
res.json(
@@ -289,7 +291,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
const companyId = req.params.companyId as string;
@@ -316,12 +318,13 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
const companyId = req.params.companyId as string;
@@ -331,18 +334,18 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
const companyId = req.params.companyId as string;
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
- const userId = getActorInfo(req).actorId;
+ const userId = responsibleUserForAiRequest(req)!;
if (
- req.actor.memberships?.some(
+ (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.some(
(m) => m.companyId === companyId && m.membershipRole === "viewer",
)
)
@@ -352,8 +355,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
+ const [session] = await db.select().from(adapterAuthSessions).where(and(
+ eq(adapterAuthSessions.companyId, req.params.companyId as string),
+ eq(adapterAuthSessions.publicSessionId, req.params.sessionId as string),
+ eq(adapterAuthSessions.startedByUserId, responsibleUserForAiRequest(req)!),
+ ));
+ if (!session?.aiConnection || !session.expiresAt || session.expiresAt.getTime() <= Date.now() ||
+ ["cancelled", "failed", "timed_out"].includes(session.status)) throw notFound("Saved sign-in is no longer available");
+ res.setHeader("Cache-Control", "no-store");
+ res.json({ sessionId: session.publicSessionId, intent: session.aiConnection, environmentId: session.environmentId,
+ status: session.status, connectionId: session.connectionId, grantId: session.connectionGrantId });
+ });
router.get(
- "/companies/:companyId/ai-connections/login/:sessionId",
+ "/companies/:companyId/ai-connections/login/:sessionId", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/login/:sessionId", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const [session] = await db
.select({
@@ -376,7 +390,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters {
- assertBoard(req);
+ assertBoardOrAgent(req);
const endpoint = await getAccessibleResource(
req,
res,
@@ -92,10 +97,24 @@ async function assertEndpointAccess(
export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
const router = Router();
+ const managementGuard = connectionManagementGuard(db, "chat");
const service = options.service ?? chatChannelService(db, options);
const access = accessService(db);
const github = githubChatManagementService(db, options.fetch);
+ async function setupHandoff(req: ExpressRequest, reason: string) {
+ const endpoint = await service.get(endpointId(req));
+ const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, endpoint.companyId));
+ const interactionId = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId
+ ? await connectionIntentService(db).requestChatHandoff({ sub: req.actor.agentId!, company_id: endpoint.companyId,
+ run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id)
+ : null;
+ return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId,
+ ...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId },
+ `/${encodeURIComponent(company!.prefix)}/apps/chat/connect?${new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id })}`, reason),
+ };
+ }
+
async function assertIdentityLinkAccess(req: ExpressRequest) {
assertBoard(req);
const userId = actorUserId(req);
@@ -123,11 +142,17 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
req: ExpressRequest,
companyId: string,
) {
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
+ if (req.actor.type === "agent") {
+ const context = connectionRequestContext(req);
+ if (!context || context.resource.companyId !== companyId) throw forbidden("Connection management authority is required");
+ await context.reauthorize();
+ return;
+ }
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)
return;
- const userId = req.actor.userId;
+ const userId = connectionRequestUserId(req);
if (
userId &&
(await access.hasPermission(
@@ -151,15 +176,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
return true;
}
- router.get("/companies/:companyId/chat-endpoints", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/chat-endpoints", managementGuard("connections:read", "GET /companies/:companyId/chat-endpoints"), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- res.json(await service.list(companyId));
+ res.json(await filterConnectionManagementRows(db, req, "connections:read", await service.list(companyId), row => ({ connectionId: row.connectionId, targetAgentId: row.assignedAgentId })));
});
router.post(
- "/companies/:companyId/chat-endpoints",
+ "/companies/:companyId/chat-endpoints", managementGuard("connections:configure", "POST /companies/:companyId/chat-endpoints"),
validate(createChatEndpointSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -170,39 +195,41 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable");
res
.status(201)
- .json(await service.create(companyId, req.body, actorUserId(req)));
+ .json(await service.create(companyId, req.body, actorUserId(req), connectionRequestContext(req)));
},
);
- router.get("/chat-endpoints/:endpointId", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId", managementGuard("connections:read", "GET /chat-endpoints/:endpointId"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await service.get(endpointId(req)));
});
const slackActor = (req: ExpressRequest) => {
- assertBoard(req);
- const actor = getActorInfo(req);
- if (actor.actorType !== "user") throw forbidden("A board session is required");
- return { userId: actor.actorId, sessionId: actor.sessionId,
- bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true };
+ assertBoardOrAgent(req);
+ const userId = connectionRequestUserId(req);
+ if (!userId) throw forbidden("A responsible user is required");
+ return { userId, sessionId: req.actor.type === "board" ? req.actor.sessionId ?? null : null,
+ managementContext: connectionRequestContext(req),
+ bypassPermissionCheck: req.actor.type === "board" && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true) };
};
- router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => {
+ router.get("/companies/:companyId/chat-slack/setup-options", managementGuard("connections:read", "GET /companies/:companyId/chat-slack/setup-options"), async (req, res) => {
const companyId = req.params.companyId as string;
await assertConnectionManager(req, companyId);
res.set("Cache-Control", "no-store");
res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req)));
});
- router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/slack/managed/authorize", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/managed/authorize"), validate(slackSetupActionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
+ if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must open the saved setup to bind their browser session and consent to Slack access.")); return; }
res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req)));
});
- router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/slack/managed/provision", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/managed/provision"), validate(slackManagedProvisionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body));
});
- router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => {
+ router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", managementGuard("connections:configure", "DELETE /companies/:companyId/chat-slack/manager-grants/:grantId", { slackManagerGrant: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await assertConnectionManager(req, companyId);
await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req));
@@ -210,6 +237,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
});
router.get("/chat-slack/managed/oauth/callback", async (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
+ assertBoard(req);
const actor = slackActor(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor);
@@ -225,23 +253,24 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
typeof req.query.code === "string" ? req.query.code : null);
res.redirect(303, await service.slackRegistration.returnPath(id));
});
- router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/slack/test-message", managementGuard("connections:test", "POST /chat-endpoints/:endpointId/slack/test-message"), validate(slackTestMessageSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body));
});
- router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/slack/registration", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/registration"), validate(slackRegistrationSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
await service.slackRegistration.create(endpointId(req), slackActor(req), req.body);
res.json(await service.get(endpointId(req)));
});
- router.post("/chat-endpoints/:endpointId/slack/install", validate(slackSetupActionSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/slack/install", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/install"), validate(slackSetupActionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
+ if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must open the saved setup to bind their browser session and consent to Slack access.")); return; }
res.json(await service.slackRegistration.install(endpointId(req), slackActor(req)));
});
- router.post("/chat-endpoints/:endpointId/slack/resume", validate(slackSetupActionSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/slack/resume", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/resume"), validate(slackSetupActionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
await service.slackRegistration.resume(endpointId(req), slackActor(req));
@@ -250,6 +279,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
router.get("/chat-slack/oauth/callback", async (req, res) => {
res.set("Cache-Control", "no-store");
res.set("Referrer-Policy", "no-referrer");
+ assertBoard(req);
const actor = slackActor(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
const expiredReturn = await service.slackRegistration.expiredReturn(state, actor);
@@ -271,145 +301,149 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
if (!userId) throw badRequest("Sign in to your Paperclip account to set up this bot");
return userId;
};
- router.get("/chat-endpoints/:endpointId/github/configuration", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/github/configuration", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/configuration"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.configuration(endpointId(req), githubUser(req)));
});
- router.put("/chat-endpoints/:endpointId/github/configuration", validate(updateGitHubChatConfigurationSchema), async (req, res) => {
+ router.put("/chat-endpoints/:endpointId/github/configuration", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/configuration"), validate(updateGitHubChatConfigurationSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
- res.json(await github.saveConfiguration(endpointId(req), req.body, githubUser(req)));
+ res.json(await github.saveConfiguration(endpointId(req), req.body, githubUser(req), connectionRequestContext(req)));
});
- router.post("/chat-endpoints/:endpointId/github/verify", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/verify", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/verify"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.verification(endpointId(req)));
});
- router.put("/chat-endpoints/:endpointId/github/progress", validate(z.object({ stage: z.enum(["setup", "connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict()), async (req, res) => {
+ router.put("/chat-endpoints/:endpointId/github/progress", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/progress"), validate(z.object({ stage: z.enum(["setup", "connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.saveGitHubSetupProgress(endpointId(req), req.body.stage));
});
- router.get("/chat-endpoints/:endpointId/github/reviews/:reviewId", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/github/reviews/:reviewId", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/reviews/:reviewId"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
const reviewId = z.string().uuid().safeParse(req.params.reviewId);
if (!reviewId.success) throw badRequest("Invalid review ID");
res.json(await github.review(endpointId(req), reviewId.data));
});
- router.get("/chat-endpoints/:endpointId/github/reviews", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/github/reviews", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/reviews"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await github.reviews(endpointId(req)));
});
- router.get("/chat-endpoints/:endpointId/github/personal-connections", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/github/personal-connections", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/personal-connections"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await github.personalConnections(endpointId(req), githubUser(req)));
});
- router.post("/chat-endpoints/:endpointId/github/identity", validate(z.object({ connectionId: z.string().uuid(), confirmedGithubUserId: z.string().regex(/^[1-9][0-9]*$/).optional() }).strict()), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/identity", (req, _res, next) => { assertBoard(req); next(); }, validate(z.object({ connectionId: z.string().uuid(), confirmedGithubUserId: z.string().regex(/^[1-9][0-9]*$/).optional() }).strict()), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await github.identity(endpointId(req), req.body.connectionId, githubUser(req), req.body.confirmedGithubUserId));
});
- router.post("/chat-endpoints/:endpointId/github/people/lookup", validate(z.object({ login: z.string().min(1).max(44) }).strict()), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/people/lookup", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/people/lookup"), validate(z.object({ login: z.string().min(1).max(44) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.lookupPerson(req.body.login));
});
- router.post("/chat-endpoints/:endpointId/github/registration", validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]).optional(), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict().refine(value => value.ownerType !== "organization" || !!value.ownerLogin, "Enter the GitHub organization")), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/registration", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/registration"), validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]).optional(), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict().refine(value => value.ownerType !== "organization" || !!value.ownerLogin, "Enter the GitHub organization")), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
- res.json(req.body.ownerType === undefined
- ? await service.startGitHubRegistration(endpointId(req), githubUser(req), req.body.name)
- : await service.githubWizard.start(endpointId(req), githubUser(req), req.body));
+ const result = req.body.ownerType === undefined
+ ? await service.startGitHubRegistration(endpointId(req), githubUser(req), req.body.name, connectionRequestContext(req))
+ : await service.githubWizard.start(endpointId(req), githubUser(req), req.body, connectionRequestContext(req));
+ res.json({ ...result, ...(req.actor.type === "agent" ? await setupHandoff(req, "Complete GitHub App registration and consent from this saved setup.") : {}) });
});
- router.post("/chat-endpoints/:endpointId/github/registration/restart", validate(z.object({ registrationId: z.string().uuid(), appNotCreated: z.literal(true) }).strict()), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/registration/restart", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/registration/restart"), validate(z.object({ registrationId: z.string().uuid(), appNotCreated: z.literal(true) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
- res.json(await service.githubWizard.restartRegistration(endpointId(req), githubUser(req), req.body.registrationId));
+ res.json(await service.githubWizard.restartRegistration(endpointId(req), githubUser(req), req.body.registrationId, connectionRequestContext(req)));
});
- router.put("/chat-endpoints/:endpointId/github/draft", validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict()), async (req, res) => {
+ router.put("/chat-endpoints/:endpointId/github/draft", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/draft"), validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
- res.json(await service.githubWizard.saveDraft(endpointId(req), githubUser(req), req.body));
+ res.json(await service.githubWizard.saveDraft(endpointId(req), githubUser(req), req.body, connectionRequestContext(req)));
});
- router.post("/chat-endpoints/:endpointId/github/setup", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/setup", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/setup"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
- res.json(await service.githubWizard.advance(endpointId(req), githubUser(req)));
+ const result = await service.githubWizard.advance(endpointId(req), githubUser(req), connectionRequestContext(req));
+ res.json({ ...result, ...(req.actor.type === "agent" && ["register", "install", "identity"].includes(result.state)
+ ? await setupHandoff(req, "Complete the required GitHub authentication or installation step from this saved setup.") : {}) });
});
- router.post("/chat-endpoints/:endpointId/github/identity/start", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/identity/start", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/identity/start"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
- res.json(await service.githubWizard.startIdentity(endpointId(req), githubUser(req)));
+ if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must authenticate and confirm their own GitHub identity.")); return; }
+ res.json(await service.githubWizard.startIdentity(endpointId(req), githubUser(req), req.actor));
});
- router.post("/chat-endpoints/:endpointId/github/identity/confirm", validate(z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict()), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/identity/confirm", (req, _res, next) => { assertBoard(req); next(); }, validate(z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.githubWizard.confirmIdentity(endpointId(req), githubUser(req), req.body.githubUserId));
});
- router.post("/chat-endpoints/:endpointId/github/app", validate(z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials")), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/app", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/app"), validate(z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials")), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
- res.json(await service.storeGitHubApp(endpointId(req), githubUser(req), req.body));
+ res.json(await service.storeGitHubApp(endpointId(req), githubUser(req), req.body, "existing_app", connectionRequestContext(req)));
});
- router.post("/chat-endpoints/:endpointId/github/repositories/refresh", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/github/repositories/refresh", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/repositories/refresh"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
- res.json(await service.refreshGitHubRepositories(endpointId(req), githubUser(req)));
+ res.json(await service.refreshGitHubRepositories(endpointId(req), githubUser(req), connectionRequestContext(req)));
});
- router.get("/chat-endpoints/:endpointId/github/repositories", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/github/repositories", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/repositories"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
const query = gitHubRepositoryPageQuerySchema.safeParse(req.query);
if (!query.success) throw badRequest("Invalid repository search or page");
res.json(await service.listGitHubRepositories(endpointId(req), query.data));
});
- router.put("/chat-endpoints/:endpointId/github/repositories/access", validate(toggleAllGitHubRepositoriesSchema), async (req, res) => {
+ router.put("/chat-endpoints/:endpointId/github/repositories/access", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/repositories/access"), validate(toggleAllGitHubRepositoriesSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.toggleAllGitHubRepositories(endpointId(req), req.body.enabled, actorUserId(req)));
});
router.patch(
- "/chat-endpoints/:endpointId",
+ "/chat-endpoints/:endpointId", managementGuard("connections:configure", "PATCH /chat-endpoints/:endpointId"),
validate(updateChatEndpointSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(
- await service.update(endpointId(req), req.body, actorUserId(req)),
+ await service.update(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)),
);
},
);
- router.post("/chat-endpoints/:endpointId/photon/inspect", validate(inspectPhotonProjectSchema), async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/photon/inspect", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/photon/inspect"), validate(inspectPhotonProjectSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.inspectPhoton(endpointId(req), req.body));
});
router.post(
- "/chat-endpoints/:endpointId/setup",
+ "/chat-endpoints/:endpointId/setup", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/setup"),
validate(configureChatEndpointSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(
- await service.configure(endpointId(req), req.body, actorUserId(req)),
+ await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)),
);
},
);
- router.post("/chat-endpoints/:endpointId/setup-secret", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/setup-secret", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/setup-secret"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res
.status(201)
.json(
- await service.generateSetupSecret(endpointId(req), actorUserId(req)),
+ await service.generateSetupSecret(endpointId(req), actorUserId(req), connectionRequestContext(req)),
);
});
- router.post("/chat-endpoints/:endpointId/test", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/test", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/test"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
- res.json(await service.test(endpointId(req)));
+ res.json(await service.test(endpointId(req), undefined, connectionRequestContext(req)));
});
- router.post("/chat-endpoints/:endpointId/finish", async (req, res) => {
+ router.post("/chat-endpoints/:endpointId/finish", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/finish"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
const userId = actorUserId(req);
if (!userId) throw badRequest("A signed-in Paperclip user is required");
- res.json(await service.finishSlackSetup(endpointId(req), userId));
+ res.json(await service.finishSlackSetup(endpointId(req), userId, connectionRequestContext(req)));
});
- router.get("/chat-endpoints/:endpointId/test-status", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/test-status", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/test-status"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
const userId = actorUserId(req);
if (!userId) throw badRequest("A signed-in Paperclip user is required");
@@ -417,13 +451,13 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
res.json(await service.setupTestStatus(endpointId(req), userId));
});
- router.get("/chat-endpoints/:endpointId/resources", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/resources", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/resources"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await service.listResources(endpointId(req)));
});
router.put(
- "/chat-endpoints/:endpointId/resources",
+ "/chat-endpoints/:endpointId/resources", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/resources"),
validate(replaceChatEndpointResourcesSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
@@ -432,18 +466,19 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
endpointId(req),
req.body.resources,
actorUserId(req),
+ { managementContext: connectionRequestContext(req) },
),
);
},
);
- router.get("/chat-endpoints/:endpointId/principals", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/principals", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/principals"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await service.listPrincipals(endpointId(req)));
});
router.post(
- "/chat-endpoints/:endpointId/principals/:principalId/link-intent",
+ "/chat-endpoints/:endpointId/principals/:principalId/link-intent", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/principals/:principalId/link-intent"),
validate(createChatIdentityLinkIntentSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
@@ -460,7 +495,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
);
router.delete(
- "/chat-endpoints/:endpointId/principals/:principalId/link",
+ "/chat-endpoints/:endpointId/principals/:principalId/link", managementGuard("connections:configure", "DELETE /chat-endpoints/:endpointId/principals/:principalId/link"),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
await service.revokeLink(
@@ -509,24 +544,24 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
res.json(preview);
});
- router.get("/chat-endpoints/:endpointId/conversations", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/conversations", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/conversations"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
- res.json(await service.listConversations(endpointId(req)));
+ res.json(await filterConnectionManagementRows(db, req, "connections:read", await service.listConversations(endpointId(req)), row => ({ issueId: row.issueId })));
});
- router.get("/chat-endpoints/:endpointId/activity", async (req, res) => {
+ router.get("/chat-endpoints/:endpointId/activity", managementGuard("connections:audit", "GET /chat-endpoints/:endpointId/activity"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
if (req.query.limit !== undefined || req.query.cursor !== undefined) {
if ((req.query.limit !== undefined && (typeof req.query.limit !== "string" || !/^\d+$/.test(req.query.limit)))
|| (req.query.cursor !== undefined && typeof req.query.cursor !== "string")) throw badRequest("Invalid activity pagination parameters");
- res.json(await service.listActivityPage(endpointId(req), req.query.limit === undefined ? 25 : Number(req.query.limit), req.query.cursor as string | undefined));
+ res.json(await service.listActivityPage(endpointId(req), req.query.limit === undefined ? 25 : Number(req.query.limit), req.query.cursor as string | undefined, connectionRequestContext(req)));
} else {
- res.json(await service.listActivity(endpointId(req)));
+ res.json(await service.listActivity(endpointId(req), undefined, connectionRequestContext(req)));
}
});
router.post(
- "/chat-endpoints/:endpointId/deliveries/:deliveryId/replay",
+ "/chat-endpoints/:endpointId/deliveries/:deliveryId/replay", (req, _res, next) => { assertBoard(req); next(); },
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
await service.replayDelivery(
@@ -538,7 +573,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
);
router.post(
- "/chat-endpoints/:endpointId/publications/:publicationId/replay",
+ "/chat-endpoints/:endpointId/publications/:publicationId/replay", (req, _res, next) => { assertBoard(req); next(); },
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
await service.replayPublication(
@@ -550,7 +585,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
);
router.post(
- "/chat-endpoints/:endpointId/publications/:publicationId/resolve",
+ "/chat-endpoints/:endpointId/publications/:publicationId/resolve", (req, _res, next) => { assertBoard(req); next(); },
validate(resolveChatPublicationSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
@@ -568,7 +603,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
);
router.post(
- "/chat-endpoints/:endpointId/actions/:actionId/resolve",
+ "/chat-endpoints/:endpointId/actions/:actionId/resolve", (req, _res, next) => { assertBoard(req); next(); },
validate(resolveChatActionSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
@@ -585,7 +620,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
);
router.post(
- "/chat-endpoints/:endpointId/conversations/:conversationId/publications",
+ "/chat-endpoints/:endpointId/conversations/:conversationId/publications", (req, _res, next) => { assertBoard(req); next(); },
validate(publishChatPublicationSchema),
async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
@@ -619,7 +654,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
);
router.get(
- "/chat-endpoints/:endpointId/conversations/:conversationId/publications/:publicationId/status",
+ "/chat-endpoints/:endpointId/conversations/:conversationId/publications/:publicationId/status", managementGuard("connections:test", "GET /chat-endpoints/:endpointId/conversations/:conversationId/publications/:publicationId/status"),
async (req, res) => {
if (
![
@@ -643,8 +678,8 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
},
);
- router.get("/issues/:issueId/chat-binding", async (req, res) => {
- assertBoard(req);
+ router.get("/issues/:issueId/chat-binding", managementGuard("connections:read", "GET /issues/:issueId/chat-binding"), async (req, res) => {
+ assertBoardOrAgent(req);
const issueId = req.params.issueId as string;
if (issueId !== issueId.trim() || !isUuidLike(issueId)) {
throw badRequest("Task ID must be a UUID");
@@ -661,6 +696,9 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
"Issue not found",
);
if (!endpoint) return;
+ await connectionManagementService(db).authorize(req.actor, "connections:read", {
+ ...connectionRequestContext(req)!.resource, connectionId: endpoint.connectionId, targetAgentId: endpoint.assignedAgentId,
+ }, { mutation: false });
}
res.json(binding);
});
diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts
new file mode 100644
index 00000000000..5bf2df5a733
--- /dev/null
+++ b/server/src/routes/connection-management-authz.ts
@@ -0,0 +1,358 @@
+import { isUuidLike, type ConnectionRequiredUserAction } from "@paperclipai/shared";
+import type { Request, RequestHandler } from "express";
+import { and, eq, or } from "drizzle-orm";
+import { type Db, toolConnections, toolApplications, toolProfiles, toolProfileEntries, toolProfileBindings,
+ toolMcpGateways, toolMcpGatewayTokens, chatEndpoints, chatConversations, connectionGrants, connectionGrantDelegations, agents, issues,
+ toolConnectionInstalls, heartbeatRuns, toolPolicies, toolRuntimeSlots, toolCatalogEntries, chatSlackManagerGrants, adapterAuthSessions, companySecrets, companies } from "@paperclipai/db";
+import { badRequest, forbidden, notFound, HttpError } from "../errors.js";
+import { assertAuthenticated, assertCompanyAccess, hasCompanyAccess, getActorInfo } from "./authz.js";
+import { connectionManagementService, responsibleConnectionUser, type ConnectionManagementContext } from "../services/connection-management.js";
+import type { ConnectionManagementAction, ConnectionManagementResource } from "../services/connection-management-policy.js";
+
+const contexts = new WeakMap();
+export function connectionRequestContext(req: Request) { return contexts.get(req); }
+export function connectionRequestUserId(req: Request) { return responsibleConnectionUser(req.actor); }
+export function connectionRequestActor(req: Request) {
+ return { ...getActorInfo(req), ...contexts.get(req)?.audit, responsibleUserId: connectionRequestUserId(req), managementContext: contexts.get(req) };
+}
+
+
+export function requiredConnectionUserAction(
+ req: Request,
+ resource: ConnectionRequiredUserAction["resource"],
+ setupPath: string,
+ reason: string,
+ kind: ConnectionRequiredUserAction["kind"] = "authenticate",
+): { requiredUserAction?: ConnectionRequiredUserAction } {
+ if (req.actor.type !== "agent") return {};
+ const userId = connectionRequestUserId(req);
+ if (!userId) throw forbidden("A responsible user is required for authentication");
+ return { requiredUserAction: { kind, userId, resource, setupPath, reason,
+ continuation: "Inspect this saved setup after the addressed user completes the required step." } };
+}
+
+/** Each REST registration declares its operation; this adapter only resolves resources. */
+export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai") {
+ const management = connectionManagementService(db);
+ return (action: ConnectionManagementAction, operation: string, options: { personal?: boolean; slackManagerGrant?: boolean } = {}): RequestHandler => async (req, _res, next) => {
+ try {
+ assertAuthenticated(req);
+ if (req.actor.type === "agent" && !connectionRequestUserId(req)) throw forbidden("Connection management requires a responsible user");
+ const inaccessibleMessage = operation.includes("setup-token-login-sessions") ? "Setup-token login session not found." : "Connection management resource not found";
+ if (req.actor.type === "board" && req.params.companyId && !hasCompanyAccess(req, String(req.params.companyId))) {
+ if (purpose === "chat" || operation.includes("setup-token-login-sessions")) throw notFound(inaccessibleMessage);
+ assertCompanyAccess(req, String(req.params.companyId));
+ }
+ const rawBody = req.body ?? {};
+ const body = purpose === "ai" && rawBody.aiConnection ? { ...rawBody, ...rawBody.aiConnection } : rawBody;
+ const userId = connectionRequestUserId(req);
+ if (req.actor.type === "agent") {
+ for (const key of ["responsibleUserId", "onBehalfOfUserId", "createdByUserId", "createdByAgentId"]) {
+ if (Object.hasOwn(body, key)) throw forbidden("Management identity is derived from authentication");
+ }
+ for (const key of ["ownerUserId", "subjectUserId"]) {
+ if (body[key] != null && body[key] !== userId) throw forbidden("Personal credentials must belong to the responsible user");
+ }
+ }
+ let companyId = typeof req.params.companyId === "string" ? req.params.companyId : null;
+ const resource: ConnectionManagementResource = { type: "connection_management", companyId: companyId ?? "", purpose, operation,
+ readOnly: req.method === "GET" && action !== "connections:gateway" && (action !== "connections:audit" || purpose === "chat") };
+ const targets = new Set();
+ const extraResources: ConnectionManagementResource[] = [];
+ const selectors: Record[] = [body, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])];
+ const bindCompany = (row: { companyId: string } | undefined) => {
+ if (!row || (req.actor.type === "board" && !hasCompanyAccess(req, row.companyId))) throw notFound(inaccessibleMessage);
+ if (companyId && companyId !== row.companyId) throw forbidden("Resource is outside this company");
+ companyId = row.companyId;
+ };
+ const uuid = (value: unknown) => { if (typeof value !== "string" || !isUuidLike(value)) throw badRequest("Expected a resource identifier"); return value; };
+ const text = (value: unknown) => typeof value === "string" && value ? value : null;
+ let connectionId = text(req.params.connectionId) ?? text(req.query.connectionId) ?? text(body.resumeConnectionId) ?? text(body.reconnectConnectionId) ?? text(body.connectionId) ?? text(req.params.poolId) ?? (operation.includes("ai-connection-pools") ? text(body.id) : null);
+ let profileId = text(req.params.profileId) ?? text(req.query.profileId) ?? text(body.profileId);
+ let gatewayId = text(req.params.gatewayId) ?? text(req.query.gatewayId) ?? text(req.query.gateway);
+ for (const id of Array.isArray(body.policyIds) ? body.policyIds : []) {
+ const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(id)));
+ if (policy && companyId && policy.companyId !== companyId && req.actor.type === "board") throw new HttpError(422, "Policies must belong to this company");
+ bindCompany(policy); selectors.push(policy.selectors);
+ }
+ if (req.params.policyId) {
+ const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(req.params.policyId)));
+ bindCompany(policy); selectors.push(policy.selectors);
+ }
+ if (action === "connections:runtime" && (req.params.slotId || req.params.id)) {
+ const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, uuid(req.params.slotId ?? req.params.id)));
+ bindCompany(slot); connectionId = slot.connectionId; resource.issueId = slot.issueId;
+ }
+ if (purpose === "ai" && req.params.sessionId && (req.actor.type === "agent" || operation.includes("/local/attempts/"))) {
+ const [session] = await db.select().from(adapterAuthSessions).where(or(eq(adapterAuthSessions.publicSessionId, String(req.params.sessionId)), ...(isUuidLike(String(req.params.sessionId)) ? [eq(adapterAuthSessions.id, String(req.params.sessionId))] : [])));
+ if (!session || session.startedByUserId !== userId) throw notFound("Sign-in attempt not found");
+ bindCompany(session); resource.selfServiceUserId = session.startedByUserId;
+ }
+ for (const ref of [...(Array.isArray(body.credentialRefs) ? body.credentialRefs : []), ...(Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : [])]) {
+ if (!ref.secretId) continue;
+ const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, uuid(ref.secretId)));
+ bindCompany(secret);
+ if (secret.deletedAt || secret.status !== "active" || (secret.scope === "user" && secret.ownerUserId !== userId)) throw forbidden("Credential binding is unavailable to the responsible user");
+ }
+ if (options.slackManagerGrant) {
+ const [grant] = await db.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, String(req.params.grantId)));
+ bindCompany(grant); resource.provider = "slack";
+ if (req.actor.type === "agent" && grant.userId !== userId) throw forbidden("Manager consent belongs to another user");
+ }
+ if (!options.slackManagerGrant && (req.params.grantId || body.grantId)) {
+ const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, uuid(req.params.grantId ?? body.grantId)));
+ bindCompany(grant);
+ if (connectionId && connectionId !== grant.connectionId) throw forbidden("Grant does not belong to this connection");
+ connectionId = grant.connectionId;
+ if (grant.subjectAgentId) targets.add(grant.subjectAgentId);
+ if (req.actor.type === "agent" && grant.kind === "user" && grant.subjectUserId !== userId) throw forbidden("Personal account belongs to another user");
+ }
+ if (req.params.delegationId) {
+ const [delegation] = await db.select().from(connectionGrantDelegations).where(eq(connectionGrantDelegations.id, uuid(req.params.delegationId)));
+ bindCompany(delegation);
+ if (delegation.grantId !== req.params.grantId) throw notFound("Connection grant delegation not found");
+ targets.add(delegation.agentId);
+ }
+ if (req.params.issueId) {
+ const [issue] = await db.select().from(issues).where(eq(issues.id, uuid(req.params.issueId)));
+ bindCompany(issue); resource.issueId = issue.id; resource.projectId = issue.projectId;
+ }
+ if (req.params.runId) {
+ const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, uuid(req.params.runId)));
+ bindCompany(run);
+ resource.issueId = run.issueId ?? (typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null);
+ }
+ if (req.params.conversationId) {
+ const [conversation] = await db.select().from(chatConversations).where(eq(chatConversations.id, uuid(req.params.conversationId)));
+ bindCompany(conversation);
+ if (req.params.endpointId && conversation.endpointId !== req.params.endpointId) throw notFound("Conversation not found");
+ resource.issueId = conversation.issueId;
+ }
+ if (body.reassignToProfileId) extraResources.push({ ...resource, profileId: uuid(body.reassignToProfileId) });
+ if (req.params.entryId) {
+ const [entry] = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, uuid(req.params.entryId)));
+ bindCompany(entry); profileId = entry.profileId; connectionId ??= entry.connectionId;
+ }
+ if (req.params.tokenId) {
+ const [token] = await db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, uuid(req.params.tokenId)));
+ bindCompany(token); gatewayId = token.gatewayId;
+ }
+ if (gatewayId) {
+ const [gateway] = await db.select().from(toolMcpGateways).where(eq(toolMcpGateways.id, uuid(gatewayId)));
+ bindCompany(gateway); profileId ??= gateway.profileId;
+ resource.gatewayId = gateway.id;
+ resource.projectId = Object.hasOwn(body, "projectId") ? text(body.projectId) : gateway.projectId;
+ resource.issueId = Object.hasOwn(body, "issueId") ? text(body.issueId) : gateway.issueId;
+ if (gateway.agentId) targets.add(gateway.agentId);
+ }
+ if (req.params.applicationId || body.applicationId) {
+ const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, uuid(req.params.applicationId ?? body.applicationId)));
+ bindCompany(application);
+ resource.provider = text(application.metadata.sourceTemplateKey);
+ if (req.method !== "GET") {
+ const connections = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, application.id));
+ connections.forEach(connection => extraResources.push({ ...resource, connectionId: connection.id }));
+ }
+ }
+ if (req.params.endpointId) {
+ const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, uuid(req.params.endpointId)));
+ bindCompany(endpoint); connectionId = endpoint.connectionId; resource.provider = endpoint.provider;
+ targets.add(endpoint.assignedAgentId);
+ }
+ if (connectionId) {
+ const [connection] = await db.select().from(toolConnections).where((isUuidLike(connectionId) ? eq(toolConnections.id, connectionId) : eq(toolConnections.uid, connectionId)));
+ bindCompany(connection); resource.connectionId = connection.id;
+ resource.purpose = connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool";
+ resource.provider ??= text(connection.config.sourceTemplateKey) ?? text(connection.config.provider) ?? text(connection.transportConfig.sourceTemplateKey);
+ resource.selfServiceUserId = connection.createdByUserId;
+ if (req.method !== "GET") {
+ const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
+ selectors.push(...installs);
+ if (operation === "PUT /tool-connections/:connectionId/installs" && Array.isArray(body.installs)) {
+ const keys = (rows: Array<{ targetType: string; targetId: string }>) => new Set(rows.filter(row => row.targetType === "company").map(row => row.targetId));
+ const existingCompanies = keys(installs), requestedCompanies = keys(body.installs);
+ resource.requestedInstallCompanyIds = [...requestedCompanies];
+ resource.selfServiceAgentInstalls = existingCompanies.size === requestedCompanies.size && [...existingCompanies].every(id => requestedCompanies.has(id));
+ }
+ }
+ if (req.actor.type === "agent" && connection.credentialPolicy === "per_user") {
+ const grants = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.kind, "user"), eq(connectionGrants.status, "active")));
+ if (!grants.some(grant => grant.subjectUserId === userId) && connection.createdByUserId !== userId) throw forbidden("Another user's personal account is not available for delegated management");
+ }
+ }
+ if (profileId) {
+ const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.id, uuid(profileId)));
+ bindCompany(profile); resource.profileId = profile.id;
+ if (req.method !== "GET") {
+ const entries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id));
+ selectors.push(...entries);
+ const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id));
+ for (const binding of bindings) {
+ if (binding.targetType === "agent") targets.add(binding.targetId);
+ if (binding.targetType === "company") {
+ const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, binding.companyId));
+ companyAgents.forEach(agent => targets.add(agent.id));
+ }
+ }
+ }
+ }
+ companyId ??= text(body.companyId) ?? text(req.query.companyId) ?? req.actor.companyId ?? null;
+ if (!companyId) throw forbidden("A company is required for connection management");
+ assertCompanyAccess(req, companyId);
+ resource.companyId = companyId;
+ resource.provider ??= text(body.provider) ?? text(body.galleryKey) ?? text(req.params.galleryKey);
+ for (const id of [req.params.agentId, req.query.agentId, req.query.agent, body.agentId, body.assignedAgentId, body.subjectAgentId, body.asAgentId, ...(Array.isArray(body.agentIds) ? body.agentIds : [])]) {
+ if (text(id)) targets.add(String(id));
+ }
+ if (body.targetType === "agent" && text(body.targetId)) targets.add(body.targetId);
+ for (const install of Array.isArray(body.installs) ? body.installs : []) {
+ if (install.targetType === "agent" && text(install.targetId)) targets.add(install.targetId);
+ }
+ if ((operation.includes("ai-connection-pools") && req.method === "POST") || body.allAgents || body.targetType === "company" || body.installs?.some?.((install: { targetType: string }) => install.targetType === "company")) {
+ const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
+ companyAgents.forEach(agent => targets.add(agent.id));
+ }
+ resource.projectId ??= text(body.projectId) ?? text(body.executionDefaults?.projectId);
+ resource.issueId ??= text(body.issueId);
+ if (body.approvalIssueId) extraResources.push({ ...resource, issueId: uuid(body.approvalIssueId) });
+ for (const member of Array.isArray(body.config?.members) ? body.config.members : []) selectors.push(member.binding ?? {});
+ for (const binding of Array.isArray(body.bindings) ? body.bindings : []) selectors.push(binding);
+ if (req.method !== "GET" && (operation.includes("/tools/policies") || operation.includes("/trust-rules/")) &&
+ selectors.some(selected => !selected.agentId && !(Array.isArray(selected.agentIds) && selected.agentIds.length))) {
+ const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
+ companyAgents.forEach(agent => targets.add(agent.id));
+ }
+ for (let index = 0; index < selectors.length; index++) {
+ const selected = selectors[index];
+ // Expand each advertised plural selector. A permission over one member
+ // cannot authorize a policy that also changes another member's access.
+ for (const key of ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId"] as const) {
+ for (const id of Array.isArray(selected[`${key}s`]) ? selected[`${key}s`] as unknown[] : []) selectors.push({ [key]: uuid(id) });
+ }
+ if (selected.applicationId) {
+ const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, uuid(selected.applicationId)));
+ bindCompany(application);
+ const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id));
+ connections.forEach(connection => selectors.push({ connectionId: connection.id }));
+ }
+ if (text(selected.profileId) && selected.profileId !== profileId) extraResources.push({ ...resource, profileId: uuid(selected.profileId) });
+ if (text(selected.gatewayId) && selected.gatewayId !== gatewayId) extraResources.push({ ...resource, gatewayId: uuid(selected.gatewayId) });
+ if (text(selected.issueId)) extraResources.push({ ...resource, issueId: uuid(selected.issueId) });
+ for (const id of [selected.agentId, selected.targetAgentId, ...(Array.isArray(selected.agentIds) ? selected.agentIds : [])]) {
+ if (text(id)) targets.add(String(id));
+ }
+ if (selected.targetType === "agent" && text(selected.targetId)) targets.add(String(selected.targetId));
+ if (selected.targetType === "company") {
+ if (text(selected.targetId) && selected.targetId !== companyId) throw forbidden("Target company does not match");
+ const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
+ rows.forEach(row => targets.add(row.id));
+ }
+ let selectedConnectionId = text(selected.connectionId);
+ if (selected.catalogEntryId) {
+ const [entry] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, String(selected.catalogEntryId)));
+ bindCompany(entry); selectedConnectionId = entry.connectionId;
+ }
+ if (selectedConnectionId && selectedConnectionId !== connectionId) {
+ const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selectedConnectionId));
+ bindCompany(connection);
+ extraResources.push({ ...resource, connectionId: connection.id,
+ provider: text(connection.config.sourceTemplateKey) ?? text(connection.config.provider),
+ selfServiceUserId: connection.createdByUserId,
+ });
+ }
+ if (selected.targetType === "project" && text(selected.targetId)) extraResources.push({ ...resource, projectId: String(selected.targetId) });
+ if (text(selected.projectId)) extraResources.push({ ...resource, projectId: String(selected.projectId) });
+ }
+ if (!connectionId && options.personal && (body.ownership === "personal" || body.grantKind === "user" || req.method === "GET")) resource.selfServiceUserId = userId;
+ for (const targetId of targets) uuid(targetId);
+ const context = await management.authorize(req.actor, action, resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
+ if (action === "connections:profiles" && resource.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resource.selfServiceAgentInstalls)) {
+ await management.authorize(req.actor, "connections:configure", resource, { targetAgentIds: [...targets] });
+ }
+ // Clearing a binding or audience still changes access. Check field
+ // presence so null, false and empty arrays cannot bypass profile authority.
+ const changesGatewayBindings = action === "connections:gateway" &&
+ ["profileId", "agentId", "projectId", "issueId"].some(key => Object.hasOwn(body, key));
+ if (changesGatewayBindings) {
+ await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
+ }
+ const changesAudience = req.actor.type === "agent" && req.method !== "GET" && action === "connections:configure" && (
+ ["allAgents", "agentIds", "assignedAgentId", "installs", "bindings", "ownership", "grantKind", "credentialPolicy"].some(key => Object.hasOwn(body, key)) ||
+ operation.includes("ai-connection-pools"));
+ if (changesAudience) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets] });
+ const authorizeReferenced = async (afterHumanAction?: boolean) => {
+ for (const referenced of extraResources) {
+ const resolved = { ...referenced, companyId: resource.companyId };
+ const checkOptions = { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction };
+ await management.authorize(req.actor, action, resolved, checkOptions);
+ if (changesGatewayBindings || changesAudience) await management.authorize(req.actor, "connections:profiles", resolved, checkOptions);
+ if (action === "connections:profiles" && resolved.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resolved.selfServiceAgentInstalls)) {
+ await management.authorize(req.actor, "connections:configure", resolved, checkOptions);
+ }
+ }
+ };
+ await authorizeReferenced();
+ const primaryReauthorize = context.reauthorize;
+ context.reauthorize = async (continuation) => {
+ await primaryReauthorize(continuation);
+ if (changesAudience) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], afterHumanAction: continuation?.afterHumanAction });
+ await authorizeReferenced(continuation?.afterHumanAction);
+ if (action === "connections:profiles" && resource.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resource.selfServiceAgentInstalls)) await management.authorize(req.actor, "connections:configure", resource, { targetAgentIds: [...targets], afterHumanAction: continuation?.afterHumanAction });
+ if (changesGatewayBindings) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction: continuation?.afterHumanAction });
+ };
+ contexts.set(req, context);
+ next();
+ } catch (error) { next(error); }
+ };
+}
+
+export async function aiLoginUserAction(db: Db, req: Request, companyId: string, sessionId: string) {
+ if (req.actor.type !== "agent") return {};
+ const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId));
+ if (!company) throw notFound("Company not found");
+ return requiredConnectionUserAction(req, { type: "ai_login", id: sessionId, companyId },
+ `/${encodeURIComponent(company.prefix)}/apps/connect?aiLogin=${encodeURIComponent(sessionId)}`,
+ "Complete the provider sign-in for this saved AI account.");
+}
+
+/** Collection projections use the same resolved-resource policy as individual reads. */
+export async function filterConnectionManagementRows(db: Db, req: Request, action: ConnectionManagementAction,
+ rows: T[], resource: (row: T) => Partial): Promise {
+ if (req.actor.type !== "agent") return rows;
+ const context = connectionRequestContext(req);
+ if (!context) throw forbidden("Connection management authority is required");
+ const visible: T[] = [];
+ for (const row of rows) {
+ const decision = await connectionManagementService(db).capability(req.actor, action, { ...context.resource, ...resource(row) }, { mutation: false });
+ if (decision.allowed) visible.push(row);
+ }
+ return visible;
+}
+
+/** Policies may name several private resources; omit the entire rule if any is hidden. */
+export async function filterConnectionManagementPolicies }>(db: Db, req: Request, rows: T[]): Promise {
+ if (req.actor.type !== "agent") return rows;
+ const context = connectionRequestContext(req);
+ if (!context) throw forbidden("Connection management authority is required");
+ const visible: T[] = [];
+ for (const row of rows) {
+ const resources: Partial[] = [{}];
+ const ids = (key: string) => [row.selectors[key], ...(Array.isArray(row.selectors[`${key}s`]) ? row.selectors[`${key}s`] as unknown[] : [])].filter((id): id is string => typeof id === "string");
+ for (const key of ["connectionId", "profileId", "gatewayId", "projectId", "issueId"] as const) for (const id of ids(key)) resources.push({ [key]: id });
+ for (const id of ids("agentId")) resources.push({ targetAgentId: id });
+ for (const applicationId of ids("applicationId")) {
+ const connections = await db.select({ connectionId: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.applicationId, applicationId), eq(toolConnections.companyId, context.resource.companyId)));
+ resources.push(...connections);
+ }
+ for (const id of ids("catalogEntryId")) {
+ const [entry] = await db.select({ connectionId: toolCatalogEntries.connectionId }).from(toolCatalogEntries).where(and(eq(toolCatalogEntries.id, id), eq(toolCatalogEntries.companyId, context.resource.companyId)));
+ if (entry) resources.push(entry);
+ }
+ let allowed = true;
+ for (const resource of resources) {
+ if (!(await connectionManagementService(db).capability(req.actor, "connections:profiles", { ...context.resource, ...resource }, { mutation: false })).allowed) { allowed = false; break; }
+ }
+ if (allowed) visible.push(row);
+ }
+ return visible;
+}
diff --git a/server/src/routes/email.ts b/server/src/routes/email.ts
index 8a67356eb9e..a22dc123aa7 100644
--- a/server/src/routes/email.ts
+++ b/server/src/routes/email.ts
@@ -1,3 +1,4 @@
+import { connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { Router, type ErrorRequestHandler, type Request } from "express";
import { z } from "zod";
import {
@@ -22,7 +23,8 @@ import type {
function actor(req: Request): EmailActor {
return req.actor.type === "agent"
- ? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined }
+ ? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined,
+ ...(connectionRequestContext(req) ? { userId: connectionRequestUserId(req) ?? undefined, managementContext: connectionRequestContext(req) } : {}) }
: {
userId: req.actor.userId ?? "board",
localImplicit: req.actor.source === "local_implicit",
@@ -86,7 +88,9 @@ const agentmailErrorHandler: ErrorRequestHandler = (error, _req, res, next) => {
export function emailRoutes(db: Db, service: EmailChannelService) {
const router = Router();
+ const managementGuard = connectionManagementGuard(db, "chat");
async function manager(req: Request, companyId: string) {
+ if (req.actor.type === "agent" && connectionRequestContext(req)) return;
assertBoard(req);
if (!hasCompanyAccess(req, companyId))
throw notFound("Email inbox not found");
@@ -104,7 +108,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
)
throw forbidden("Missing permission: tools:manage_connections");
}
- router.get("/companies/:companyId/email/connections", async (req, res) => {
+ router.get("/companies/:companyId/email/connections", managementGuard("connections:read", "GET /companies/:companyId/email/connections", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
res.set("Cache-Control", "no-store").json(
@@ -112,7 +116,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
);
});
router.post(
- "/companies/:companyId/email/connections",
+ "/companies/:companyId/email/connections", managementGuard("connections:configure", "POST /companies/:companyId/email/connections", { personal: true }),
validate(emailConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -129,7 +133,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
- "/companies/:companyId/email/connections/:connectionId/inspect",
+ "/companies/:companyId/email/connections/:connectionId/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/inspect", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
@@ -144,7 +148,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
- "/companies/:companyId/email/connections/:connectionId/check-address",
+ "/companies/:companyId/email/connections/:connectionId/check-address", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/check-address", { personal: true }),
validate(emailAddressCheckSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -167,7 +171,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
);
});
router.post(
- "/companies/:companyId/email/inspect",
+ "/companies/:companyId/email/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/inspect", { personal: true }),
validate(z.object({ apiKey: z.string().min(1).max(4096) }).strict()),
async (req, res) => {
await manager(req, req.params.companyId as string);
@@ -176,7 +180,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
- "/companies/:companyId/email/inboxes",
+ "/companies/:companyId/email/inboxes", managementGuard("connections:configure", "POST /companies/:companyId/email/inboxes", { personal: true }),
validate(emailEndpointSetupSchema),
async (req, res) => {
await manager(req, req.params.companyId as string);
@@ -192,7 +196,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
- "/email/inboxes/:endpointId/control",
+ "/email/inboxes/:endpointId/control", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/control", { personal: true }),
validate(
z.object({ action: z.enum(["pause", "resume", "remove"]) }).strict(),
),
@@ -205,7 +209,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
- "/email/inboxes/:endpointId/reconnect",
+ "/email/inboxes/:endpointId/reconnect", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/reconnect", { personal: true }),
validate(
z
.object({
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 3946cac4d74..22c8f20edf7 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -1271,6 +1271,8 @@ function paramsSchemaFromPath(
}
function registerCurrentRoute(input: {
+ "x-paperclip-transport"?: "rest" | "protocol";
+ "x-paperclip-connection-management"?: string;
method: string;
path: string;
tags: string[];
@@ -1291,6 +1293,7 @@ function registerCurrentRoute(input: {
registry.registerPath({
method: input.method,
path: input.path,
+ ...(input["x-paperclip-transport"] ? { "x-paperclip-transport": input["x-paperclip-transport"], "x-paperclip-connection-management": input["x-paperclip-connection-management"] } : {}),
tags: input.tags,
summary: input.summary,
...(request ? { request } : {}),
@@ -1399,6 +1402,9 @@ const BOARD_ONLY_OPERATIONS = new Set([
"PUT /api/companies/{companyId}/prompt-injection-prevention",
"GET /api/companies/{companyId}/prompt-injection-prevention/models",
"POST /api/companies/{companyId}/prompt-injection-prevention/test",
+ "POST /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
+ "POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
+ "POST /api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
"GET /api/companies/{companyId}/fast-response",
"PUT /api/companies/{companyId}/fast-response",
"GET /api/companies/{companyId}/fast-response/models",
@@ -1412,21 +1418,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
"DELETE /api/companies/{companyId}/observability",
"POST /api/companies/{companyId}/observability/test",
...browserUseOperations.map(([method, path]) => `${method.toUpperCase()} ${path}`),
- "GET /api/companies/{companyId}/ai-connections",
- "POST /api/companies/{companyId}/ai-connections",
- "POST /api/companies/{companyId}/ai-connections/local",
- "POST /api/companies/{companyId}/ai-connections/local/attempts",
- "POST /api/companies/{companyId}/ai-connections/local/check",
"POST /api/companies/{companyId}/ai-connections/local/attempts/{sessionId}/code",
- "DELETE /api/companies/{companyId}/ai-connections/local/attempts/{sessionId}",
- "PUT /api/companies/{companyId}/ai-connections/default",
- "GET /api/companies/{companyId}/ai-connections/{connectionId}/active-runs",
- "GET /api/companies/{companyId}/ai-connections/{connectionId}/usage",
- "GET /api/companies/{companyId}/ai-connections/login/{sessionId}",
- "GET /api/companies/{companyId}/ai-connection-pools",
- "POST /api/companies/{companyId}/ai-connection-pools",
- "DELETE /api/companies/{companyId}/ai-connection-pools/{poolId}",
- "GET /api/companies/{companyId}/ai-connection-pools/{poolId}/inspection",
"GET /api/companies/{companyId}/project-repositories",
"PUT /api/projects/{id}/repositories",
@@ -1503,54 +1495,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
"POST /api/issues/{id}/interactions/{interactionId}/respond",
"POST /api/issues/{id}/interactions/{interactionId}/skip",
"POST /api/issues/{id}/interactions/{interactionId}/withdraw",
- "GET /api/companies/{companyId}/tools/gallery",
- "GET /api/companies/{companyId}/tools/apps/{galleryKey}/preflight",
- "POST /api/companies/{companyId}/tools/apps/connect",
- "POST /api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access",
- "POST /api/companies/{companyId}/tools/apps/{connectionId}/finish",
- "GET /api/companies/{companyId}/tools/apps/attention",
- "GET /api/companies/{companyId}/tools/action-requests",
- "GET /api/companies/{companyId}/tools/examples",
- "POST /api/companies/{companyId}/tools/examples/{id}/install",
"POST /api/companies/{companyId}/tools/examples/{id}/smoke",
- "GET /api/companies/{companyId}/tools/applications",
- "POST /api/companies/{companyId}/tools/applications",
- "PATCH /api/tool-applications/{applicationId}",
- "DELETE /api/tool-applications/{applicationId}",
- "GET /api/companies/{companyId}/tools/connections",
- "POST /api/companies/{companyId}/tools/connections",
- "POST /api/companies/{companyId}/tools/connections/{connectionId}/start-authorization",
- "GET /api/tool-connections/{connectionId}",
- "GET /api/tool-connections/{connectionId}/grants",
- "POST /api/tool-connections/{connectionId}/grants/installations",
- "POST /api/tool-connections/{connectionId}/grants/{grantId}/delegations",
- "DELETE /api/tool-connections/{connectionId}/grants/{grantId}/delegations/{delegationId}",
- "DELETE /api/tool-connections/{connectionId}/grants/{grantId}",
- "GET /api/tool-connections/{connectionId}/usage",
- "PATCH /api/tool-connections/{connectionId}",
- "DELETE /api/tool-connections/{connectionId}",
- "POST /api/tool-connections/{connectionId}/health-check",
- "POST /api/tool-connections/{connectionId}/reconnect",
- "POST /api/tool-connections/{connectionId}/railway/ssh",
- "POST /api/tool-connections/{connectionId}/catalog/refresh",
- "GET /api/tool-connections/{connectionId}/catalog",
- "GET /api/tool-connections/{connectionId}/aggregator/apps",
- "POST /api/tool-connections/{connectionId}/aggregator/apps/sync",
- "POST /api/tool-connections/{connectionId}/aggregator/apps/refresh",
- "PUT /api/tool-connections/{connectionId}/aggregator/discovery",
- "GET /api/tool-connections/{connectionId}/composio/apps",
- "POST /api/tool-connections/{connectionId}/composio/apps/sync",
- "POST /api/tool-connections/{connectionId}/composio/apps/refresh",
- "POST /api/tool-connections/{connectionId}/composio/apps/{toolkit}/setup",
- "POST /api/tool-connections/{connectionId}/composio/apps/{toolkit}/accounts",
- "GET /api/tool-connections/{connectionId}/activity",
- "GET /api/tool-connections/{connectionId}/test-agents",
- "GET /api/tool-connections/{connectionId}/test-agents/{agentId}/access",
- "POST /api/tool-connections/{connectionId}/test-calls",
- "GET /api/tool-connections/{connectionId}/test-calls/{actionRequestId}",
- "POST /api/agents/me/connections/{connectionId}/start-authorization",
"POST /api/agents/me/connections/{connectionId}/token",
- "POST /api/tools/oauth/{connectionId}/start",
"GET /api/tools/oauth/callback",
"GET /api/tools/vercel-connect/callback",
"GET /api/connection-intents/{interactionId}/setup-options",
@@ -1558,53 +1504,12 @@ const BOARD_ONLY_OPERATIONS = new Set([
"POST /api/connection-intents/{interactionId}/complete",
"POST /api/agents/{id}/connection-intents/{interactionId}/adopt",
"POST /api/connection-intents/{interactionId}/decline",
- "GET /api/companies/{companyId}/tools/profiles",
- "POST /api/companies/{companyId}/tools/profiles",
- "GET /api/companies/{companyId}/tools/profiles/effective/agents/{agentId}",
- "GET /api/tool-profiles/{profileId}/new-tools",
- "PATCH /api/tool-profiles/{profileId}",
- "POST /api/tool-profiles/{profileId}/duplicate",
- "DELETE /api/tool-profiles/{profileId}",
- "POST /api/tool-profiles/{profileId}/new-tools/review",
- "POST /api/tool-profiles/{profileId}/entries",
- "PATCH /api/tool-profile-entries/{entryId}",
- "DELETE /api/tool-profile-entries/{entryId}",
- "POST /api/companies/{companyId}/tools/profiles/{profileId}/bind",
- "POST /api/companies/{companyId}/tools/profiles/{profileId}/unbind",
- "GET /api/companies/{companyId}/tools/runtime-slots",
- "POST /api/companies/{companyId}/tools/runtime-slots/{id}/stop",
- "POST /api/companies/{companyId}/tools/runtime-slots/{id}/restart",
- "GET /api/companies/{companyId}/tools/runtime-health",
- "GET /api/companies/{companyId}/tools/runs/{runId}/decisions",
- "GET /api/companies/{companyId}/tools/trust-rules",
- "GET /api/companies/{companyId}/tools/policies",
- "POST /api/companies/{companyId}/tools/policies/reorder",
- "POST /api/companies/{companyId}/tools/policies",
- "POST /api/companies/{companyId}/tools/policies/{policyId}/duplicate",
- "PATCH /api/companies/{companyId}/tools/policies/{policyId}",
- "DELETE /api/companies/{companyId}/tools/policies/{policyId}",
"POST /api/companies/{companyId}/tools/action-requests/{actionRequestId}/trust-rule",
- "POST /api/companies/{companyId}/tools/trust-rules/{policyId}/revoke",
- "GET /api/companies/{companyId}/tools/stdio-templates",
"POST /api/companies/{companyId}/tools/stdio-templates",
"POST /api/companies/{companyId}/tools/stdio-templates/{templateId}/disable",
"POST /api/companies/{companyId}/tools/mcp/import-json",
- "POST /api/companies/{companyId}/tools/policy/test",
- "GET /api/companies/{companyId}/tools/gateways",
- "POST /api/companies/{companyId}/tools/gateways",
- "PATCH /api/tool-gateway/gateways/{gatewayId}",
- "POST /api/tool-gateway/gateways/{gatewayId}/tokens",
- "POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke",
"POST /api/tool-gateway/action-requests/{id}/approve",
"POST /api/tool-gateway/action-requests/{id}/decline",
- "POST /api/companies/{companyId}/email/inspect",
- "POST /api/companies/{companyId}/email/inboxes",
- "GET /api/companies/{companyId}/email/connections",
- "POST /api/companies/{companyId}/email/connections",
- "POST /api/companies/{companyId}/email/connections/{connectionId}/inspect",
- "POST /api/companies/{companyId}/email/connections/{connectionId}/check-address",
- "POST /api/email/inboxes/{endpointId}/control",
- "POST /api/email/inboxes/{endpointId}/reconnect",
"POST /api/companies/{companyId}/email/deliveries/{publicationId}/resolve",
// Chat endpoints expose provider credentials, identity mappings, access
// policy, and replay controls. Every mounted handler asserts a board actor;
@@ -1615,52 +1520,13 @@ const BOARD_ONLY_OPERATIONS = new Set([
"PUT /api/companies/{companyId}/slack/endpoints/{endpointId}/search",
"POST /api/companies/{companyId}/slack/endpoints/{endpointId}/search/connect",
"DELETE /api/companies/{companyId}/slack/endpoints/{endpointId}/search",
- "GET /api/companies/{companyId}/chat-endpoints",
- "POST /api/companies/{companyId}/chat-endpoints",
- "GET /api/chat-endpoints/{endpointId}",
- "GET /api/chat-endpoints/{endpointId}/github/configuration",
- "PUT /api/chat-endpoints/{endpointId}/github/configuration",
- "POST /api/chat-endpoints/{endpointId}/github/verify",
- "PUT /api/chat-endpoints/{endpointId}/github/progress",
- "GET /api/chat-endpoints/{endpointId}/github/reviews",
- "GET /api/chat-endpoints/{endpointId}/github/reviews/{reviewId}",
- "GET /api/chat-endpoints/{endpointId}/github/repositories",
- "PUT /api/chat-endpoints/{endpointId}/github/repositories/access",
- "GET /api/chat-endpoints/{endpointId}/github/personal-connections",
"POST /api/chat-endpoints/{endpointId}/github/identity",
- "POST /api/chat-endpoints/{endpointId}/github/people/lookup",
- "POST /api/chat-endpoints/{endpointId}/github/registration",
- "POST /api/chat-endpoints/{endpointId}/github/registration/restart",
- "PUT /api/chat-endpoints/{endpointId}/github/draft",
- "POST /api/chat-endpoints/{endpointId}/github/setup",
- "POST /api/chat-endpoints/{endpointId}/github/identity/start",
"POST /api/chat-endpoints/{endpointId}/github/identity/confirm",
- "POST /api/chat-endpoints/{endpointId}/github/app",
- "POST /api/chat-endpoints/{endpointId}/github/repositories/refresh",
- "PATCH /api/chat-endpoints/{endpointId}",
- "POST /api/chat-endpoints/{endpointId}/setup",
- "POST /api/chat-endpoints/{endpointId}/setup-secret",
- "POST /api/chat-endpoints/{endpointId}/test",
- "POST /api/chat-endpoints/{endpointId}/finish",
- "GET /api/chat-endpoints/{endpointId}/test-status",
- "POST /api/chat-endpoints/{endpointId}/photon/inspect",
- "GET /api/chat-endpoints/{endpointId}/resources",
- "PUT /api/chat-endpoints/{endpointId}/resources",
- "GET /api/chat-endpoints/{endpointId}/principals",
- "POST /api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent",
- "DELETE /api/chat-endpoints/{endpointId}/principals/{principalId}/link",
"POST /api/chat-identity-links/confirm",
"GET /api/chat-identity-links/preview",
"POST /api/chat-identity-links/request-access",
- "GET /api/chat-endpoints/{endpointId}/conversations",
- "GET /api/chat-endpoints/{endpointId}/activity",
- "POST /api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
- "POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
"POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve",
"POST /api/chat-endpoints/{endpointId}/actions/{actionId}/resolve",
- "POST /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
- "GET /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status",
- "GET /api/issues/{issueId}/chat-binding",
]);
const INSTANCE_ADMIN_OPERATIONS = new Set([
@@ -1674,7 +1540,6 @@ const INSTANCE_ADMIN_OPERATIONS = new Set([
const CREATED_OPERATIONS = new Set([
"POST /api/adapters/install",
- "POST /api/chat-endpoints/{endpointId}/setup-secret",
"POST /api/companies/{companyId}/agent-hires",
"POST /api/companies/{companyId}/agents",
"POST /api/agents/{id}/keys",
@@ -1723,11 +1588,7 @@ const CREATED_OPERATIONS = new Set([
"POST /api/admin/users/{userId}/promote-instance-admin",
"POST /api/plugins/install",
"POST /api/instance/database-backups",
- "POST /api/companies/{companyId}/tools/applications",
- "POST /api/companies/{companyId}/tools/connections",
"POST /api/companies/{companyId}/tools/action-requests/{actionRequestId}/trust-rule",
- "POST /api/companies/{companyId}/tools/gateways",
- "POST /api/tool-gateway/gateways/{gatewayId}/tokens",
"POST /api/tool-gateway/sessions",
]);
@@ -1847,7 +1708,7 @@ function applyDocumentFixups(document: any): any {
for (const [method, operation] of Object.entries(
pathItem as Record,
)) {
- const authLevel = resolveOperationAuthLevel(method, path);
+ const authLevel = operation["x-paperclip-connection-management"] ? "authenticated" : resolveOperationAuthLevel(method, path);
if (authLevel === "public") {
operation.security = [];
} else if (authLevel === "agent_run") {
@@ -2326,6 +2187,7 @@ for (const [method,path,summary,body,response] of [
// Explicit task-bound email. Board setup and agent actions share the same vaulted
// connection, while automatic chat publication never applies to these endpoints.
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/email/connections/{connectionId}/check-address",
tags: ["Email"],
@@ -2357,6 +2219,8 @@ for (const [method, path, summary, body, success] of [
["post", "/api/companies/{companyId}/email/deliveries/{publicationId}/resolve", "Resolve uncertain email after checking the provider", z.object({ outcome: z.enum(["sent", "failed"]), providerMessageId: z.string().min(1).max(998).optional() }).strict(), 200],
] as const) {
registry.registerPath({ method, path, tags: ["Email"], summary,
+ ...(!path.includes("/send") && !path.includes("/tasks/") && !path.includes("/deliveries/") && !(method === "get" && path.endsWith("/inboxes"))
+ ? { "x-paperclip-transport": "rest", "x-paperclip-connection-management": method === "get" ? "connections:read" : "connections:configure" } : {}),
description: "AgentMail email connection. Available without the experimental chat setting. Internal comments never send email. Agent sends require assigned inbox and task ownership, active run authority, and configured action policies. Preserve the same idempotencyKey and payload across retries. New conversations create an email child task; replies require conversationId and replyToMessageId. Reply-all is deliberate and never includes Bcc.",
request: { params: z.object(Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].map(match => [match[1], z.string().uuid()]))), ...(body ? { body: jsonBody(body) } : {}) },
responses: { [success]: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
@@ -2388,6 +2252,8 @@ registry.registerPath({ method: "get", path: "/api/slack/search/callback", tags:
// ─── Chat Channels ─────────────────────────────────────────────────────────
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/chat-endpoints",
tags: ["chat-channels"],
@@ -2403,6 +2269,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/chat-endpoints",
tags: ["chat-channels"],
@@ -2424,6 +2292,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/chat-endpoints/{endpointId}",
tags: ["chat-channels"],
@@ -2564,6 +2434,7 @@ for (const operation of githubBotOperations) {
registry.registerPath({
method: operation.method,
path: `/api/chat-endpoints/{endpointId}/github/${operation.suffix}`,
+ ...(!["identity", "identity/confirm"].includes(operation.suffix) ? { "x-paperclip-transport": "rest", "x-paperclip-connection-management": operation.method === "get" ? "connections:read" : "connections:configure" } : {}),
tags: ["chat-channels"], summary: operation.summary, description: operation.description,
request: {
params: z.object({ endpointId: z.string().uuid(), ...(operation.suffix.includes("{reviewId}") ? { reviewId: z.string().uuid() } : {}) }),
@@ -2580,6 +2451,8 @@ for (const operation of githubBotOperations) {
}
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "patch",
path: "/api/chat-endpoints/{endpointId}",
tags: ["chat-channels"],
@@ -2600,6 +2473,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/chat-endpoints/{endpointId}/setup",
tags: ["chat-channels"],
@@ -2625,19 +2500,23 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"],
summary: "Discover Slack setup methods and the current user's authorized workspaces",
description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.",
request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden },
});
for (const action of ["authorize", "provision"] as const) registry.registerPath({
- method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"],
+ method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", tags: ["chat-channels"],
summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app",
description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.",
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) },
responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"],
summary: "Revoke the current user's workspace grant for future managed provisioning",
description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.",
@@ -2652,6 +2531,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:test",
method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"],
summary: "Connect the current user's Slack identity and send a test DM",
description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.",
@@ -2661,7 +2542,7 @@ registry.registerPath({
for (const action of ["registration", "install", "resume"] as const) {
registry.registerPath({
- method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"],
+ method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", tags: ["chat-channels"],
summary: { registration: "Create a Slack app for a saved draft", install: "Authorize installation of the saved Slack app", resume: "Connect already-vaulted Slack installation credentials" }[action],
description: "Requires a board session, company membership, connection-management permission, and the chat connector rollout gate. Secrets are never returned. Creation request IDs are idempotent; uncertain creation requires an explicit checked-no-app confirmation before a new attempt. OAuth installation is bound to the initiating actor/session and expires in ten minutes.",
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "registration" ? slackRegistrationSchema : slackSetupActionSchema) },
@@ -2677,6 +2558,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/chat-endpoints/{endpointId}/setup-secret",
tags: ["chat-channels"],
@@ -2695,6 +2578,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/chat-endpoints/{endpointId}/photon/inspect",
tags: ["chat-channels"],
@@ -2730,6 +2615,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post", path: "/api/chat-endpoints/{endpointId}/finish", tags: ["chat-channels"],
summary: "Finish Slack or GitHub onboarding with an optional conversation test",
description: "Requires verified provider delivery and an authorized identity linked to the current user. GitHub also checks the assigned agent’s current bot capabilities. Records that a full conversation test was not required.",
@@ -2737,6 +2624,8 @@ registry.registerPath({
responses: { 200: r.ok(chatEndpointResponseSchema), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get", path: "/api/chat-endpoints/{endpointId}/test-status", tags: ["chat-channels"],
summary: "Check for the current user's first setup message",
request: { params: z.object({ endpointId: z.string().uuid() }) },
@@ -2751,6 +2640,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/chat-endpoints/{endpointId}/test",
tags: ["chat-channels"],
@@ -2768,6 +2659,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/chat-endpoints/{endpointId}/resources",
tags: ["chat-channels"],
@@ -2784,6 +2677,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "put",
path: "/api/chat-endpoints/{endpointId}/resources",
tags: ["chat-channels"],
@@ -2806,6 +2701,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/chat-endpoints/{endpointId}/principals",
tags: ["chat-channels"],
@@ -2822,6 +2719,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent",
tags: ["chat-channels"],
@@ -2847,6 +2746,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete",
path: "/api/chat-endpoints/{endpointId}/principals/{principalId}/link",
tags: ["chat-channels"],
@@ -2905,6 +2806,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/chat-endpoints/{endpointId}/conversations",
tags: ["chat-channels"],
@@ -2921,6 +2824,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:audit",
method: "get",
path: "/api/chat-endpoints/{endpointId}/activity",
tags: ["chat-channels"],
@@ -3057,6 +2962,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/issues/{issueId}/chat-binding",
tags: ["chat-channels", "issues"],
@@ -3074,6 +2980,8 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:test",
method: "get",
path: "/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status",
tags: ["chat-channels"],
@@ -4178,6 +4086,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions",
tags: ["adapters"],
@@ -4196,6 +4105,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/active",
tags: ["adapters"],
@@ -4212,6 +4122,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}",
tags: ["adapters"],
@@ -4232,6 +4143,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}/cancel",
tags: ["adapters"],
@@ -7934,6 +7846,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/setup-token-login-sessions",
tags: ["companies"],
@@ -7953,6 +7866,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/setup-token-login-sessions/active",
tags: ["companies"],
@@ -7967,6 +7881,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}",
tags: ["companies"],
@@ -7983,6 +7898,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/prompt",
tags: ["companies"],
@@ -8018,6 +7934,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/completion",
tags: ["companies"],
@@ -8035,6 +7952,7 @@ registry.registerPath({
});
registry.registerPath({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/cancel",
tags: ["companies"],
@@ -11095,6 +11013,8 @@ registerCurrentRoute({
// --- AI runtime connections -------------------------------------------------
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/ai-connection-pools",
tags: ["ai-connections"],
@@ -11103,6 +11023,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/ai-connection-pools",
tags: ["ai-connections"],
@@ -11112,6 +11034,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete",
path: "/api/companies/{companyId}/ai-connection-pools/{poolId}",
tags: ["ai-connections"],
@@ -11121,6 +11045,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection",
tags: ["ai-connections"],
@@ -11129,6 +11055,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/ai-connections/{connectionId}/usage",
tags: ["ai-connections"],
@@ -11138,6 +11066,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/ai-connections",
tags: ["ai-connections"],
@@ -11147,6 +11077,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/ai-connections",
tags: ["ai-connections"],
@@ -11156,6 +11088,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "put",
path: "/api/companies/{companyId}/ai-connections/default",
tags: ["ai-connections"],
@@ -11165,6 +11099,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/ai-connections/{connectionId}/active-runs",
tags: ["ai-connections"],
@@ -11173,6 +11109,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/ai-connections/login/{sessionId}",
tags: ["ai-connections"],
@@ -11180,6 +11118,13 @@ registerCurrentRoute({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
+registerCurrentRoute({
+ method: "get", path: "/api/companies/{companyId}/ai-connections/login/{sessionId}/setup",
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
+ tags: ["ai-connections"], summary: "Reopen an owned saved AI sign-in with its original account and environment",
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
// --- Tool access -------------------------------------------------------------
for (const [method, suffix, summary] of [
@@ -11269,6 +11214,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/gallery",
tags: ["tool-access"],
@@ -11276,6 +11223,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/apps/{galleryKey}/preflight",
tags: ["tool-access"],
@@ -11284,6 +11233,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/apps/connect",
tags: ["tool-access"],
@@ -11300,6 +11251,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/apps/{connectionId}/finish",
tags: ["tool-access"],
@@ -11316,6 +11269,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access",
tags: ["tool-access"],
@@ -11332,6 +11287,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/apps/attention",
tags: ["tool-access"],
@@ -11339,6 +11296,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/action-requests",
tags: ["tool-access"],
@@ -11346,6 +11305,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/examples",
tags: ["tool-access"],
@@ -11353,6 +11314,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/examples/{id}/install",
tags: ["tool-access"],
@@ -11382,6 +11345,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/applications",
tags: ["tool-access"],
@@ -11389,6 +11354,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/applications",
tags: ["tool-access"],
@@ -11403,6 +11370,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "patch",
path: "/api/tool-applications/{applicationId}",
tags: ["tool-access"],
@@ -11411,6 +11380,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete",
path: "/api/tool-applications/{applicationId}",
tags: ["tool-access"],
@@ -11418,6 +11389,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/connections",
tags: ["tool-access"],
@@ -11425,6 +11398,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/connections",
tags: ["tool-access"],
@@ -11439,6 +11414,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}",
tags: ["tool-access"],
@@ -11446,6 +11423,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/connections/{connectionId}/start-authorization",
tags: ["tool-access"],
@@ -11454,6 +11433,7 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/agents/me/connections/{connectionId}/start-authorization",
tags: ["tool-access"],
@@ -11462,6 +11442,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}/grants",
tags: ["tool-access"],
@@ -11469,6 +11451,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/tool-connections/{connectionId}/grants/installations",
tags: ["tool-access"],
@@ -11483,6 +11467,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/tool-connections/{connectionId}/grants/{grantId}/delegations",
tags: ["tool-access"],
@@ -11498,6 +11484,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "delete",
path: "/api/tool-connections/{connectionId}/grants/{grantId}/delegations/{delegationId}",
tags: ["tool-access"],
@@ -11505,6 +11493,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete",
path: "/api/tool-connections/{connectionId}/grants/{grantId}",
tags: ["tool-access"],
@@ -11512,6 +11502,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}/usage",
tags: ["tool-access"],
@@ -11519,6 +11511,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "put",
path: "/api/tool-connections/{connectionId}/grants/{grantId}/members",
tags: ["tool-access"],
@@ -11526,6 +11520,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/tool-connections/{connectionId}/railway/ssh",
tags: ["tool-access"],
@@ -11543,6 +11539,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/tool-connections/{connectionId}/installs",
tags: ["tool-access"],
@@ -11550,6 +11548,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "put",
path: "/api/tool-connections/{connectionId}/installs",
tags: ["tool-access"],
@@ -11558,6 +11558,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "patch",
path: "/api/tool-connections/{connectionId}",
tags: ["tool-access"],
@@ -11566,6 +11568,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete",
path: "/api/tool-connections/{connectionId}",
tags: ["tool-access"],
@@ -11573,6 +11577,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/tool-connections/{connectionId}/health-check",
tags: ["tool-access"],
@@ -11580,6 +11586,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/tool-connections/{connectionId}/reconnect",
tags: ["tool-access"],
@@ -11595,6 +11603,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/tool-connections/{connectionId}/catalog/refresh",
tags: ["tool-access"],
@@ -11602,6 +11612,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}/catalog",
tags: ["tool-access"],
@@ -11609,6 +11621,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:audit",
method: "get",
path: "/api/tool-connections/{connectionId}/activity",
tags: ["tool-access"],
@@ -11617,37 +11631,48 @@ registerCurrentRoute({
for (const provider of ["aggregator", "composio"] as const) {
registerCurrentRoute({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get", path: `/api/tool-connections/{connectionId}/${provider}/apps`, tags: ["tool-access"],
summary: "List upstream account observations for the current connection manager",
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post", path: `/api/tool-connections/{connectionId}/${provider}/apps/sync`, tags: ["tool-access"],
summary: "Start upstream account discovery without changing tool access",
body: provider === "aggregator" ? aggregatorAppsSyncSchema : composioAppsSyncSchema,
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure",
method: "post", path: `/api/tool-connections/{connectionId}/${provider}/apps/refresh`, tags: ["tool-access"],
summary: "Refresh upstream account observations",
body: provider === "aggregator" ? aggregatorAppsRefreshSchema : composioAppsRefreshSchema,
});
}
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "put", path: "/api/tool-connections/{connectionId}/aggregator/discovery", tags: ["tool-access"],
summary: "Save manager-owned optional Arcade account discovery credentials",
body: arcadeDiscoverySetupSchema,
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post", path: "/api/tool-connections/{connectionId}/composio/apps/{toolkit}/setup", tags: ["tool-access"],
summary: "Start or verify Composio app authorization through a saved gateway",
body: composioAppSetupSchema,
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post", path: "/api/tool-connections/{connectionId}/composio/apps/{toolkit}/accounts", tags: ["tool-access"],
summary: "Manage a Composio account through a saved gateway",
body: composioAppAccountSchema,
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}/test-agents",
tags: ["tool-access"],
@@ -11655,6 +11680,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/tool-connections/{connectionId}/test-agents/{agentId}/access",
tags: ["tool-access"],
@@ -11662,6 +11689,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:test",
method: "post",
path: "/api/tool-connections/{connectionId}/test-calls",
tags: ["tool-access"],
@@ -11679,6 +11708,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:test",
method: "get",
path: "/api/tool-connections/{connectionId}/test-calls/{actionRequestId}",
tags: ["tool-access"],
@@ -11694,6 +11725,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/tools/oauth/{connectionId}/start",
tags: ["tool-access"],
@@ -11768,6 +11801,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/companies/{companyId}/tools/profiles",
tags: ["tool-access"],
@@ -11775,6 +11810,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/profiles",
tags: ["tool-access"],
@@ -11789,6 +11826,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/companies/{companyId}/tools/profiles/effective/agents/{agentId}",
tags: ["tool-access"],
@@ -11796,6 +11835,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/tool-profiles/{profileId}/new-tools",
tags: ["tool-access"],
@@ -11803,6 +11844,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "patch",
path: "/api/tool-profiles/{profileId}",
tags: ["tool-access"],
@@ -11811,6 +11854,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/tool-profiles/{profileId}/duplicate",
tags: ["tool-access"],
@@ -11825,6 +11870,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "delete",
path: "/api/tool-profiles/{profileId}",
tags: ["tool-access"],
@@ -11839,6 +11886,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/tool-profiles/{profileId}/new-tools/review",
tags: ["tool-access"],
@@ -11853,6 +11902,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/tool-profiles/{profileId}/entries",
tags: ["tool-access"],
@@ -11867,6 +11918,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "patch",
path: "/api/tool-profile-entries/{entryId}",
tags: ["tool-access"],
@@ -11875,6 +11928,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "delete",
path: "/api/tool-profile-entries/{entryId}",
tags: ["tool-access"],
@@ -11882,6 +11937,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/profiles/{profileId}/bind",
tags: ["tool-access"],
@@ -11898,6 +11955,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/profiles/{profileId}/unbind",
tags: ["tool-access"],
@@ -11907,6 +11966,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "get",
path: "/api/companies/{companyId}/tools/runtime-slots",
tags: ["tool-access"],
@@ -11914,6 +11975,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "post",
path: "/api/companies/{companyId}/tools/runtime-slots/{id}/stop",
tags: ["tool-access"],
@@ -11921,6 +11984,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "post",
path: "/api/companies/{companyId}/tools/runtime-slots/{id}/restart",
tags: ["tool-access"],
@@ -11928,6 +11993,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "get",
path: "/api/companies/{companyId}/tools/runtime-health",
tags: ["tool-access"],
@@ -11935,6 +12002,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:audit",
method: "get",
path: "/api/companies/{companyId}/tools/runs/{runId}/decisions",
tags: ["tool-access"],
@@ -11942,6 +12011,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/companies/{companyId}/tools/trust-rules",
tags: ["tool-access"],
@@ -11949,6 +12020,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/companies/{companyId}/tools/policies",
tags: ["tool-access"],
@@ -11956,6 +12029,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/policies/reorder",
tags: ["tool-access"],
@@ -11964,6 +12039,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/policies",
tags: ["tool-access"],
@@ -11978,6 +12055,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/policies/{policyId}/duplicate",
tags: ["tool-access"],
@@ -11992,6 +12071,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "patch",
path: "/api/companies/{companyId}/tools/policies/{policyId}",
tags: ["tool-access"],
@@ -12000,6 +12081,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "delete",
path: "/api/companies/{companyId}/tools/policies/{policyId}",
tags: ["tool-access"],
@@ -12021,6 +12104,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:profiles",
method: "post",
path: "/api/companies/{companyId}/tools/trust-rules/{policyId}/revoke",
tags: ["tool-access"],
@@ -12029,6 +12114,7 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read",
method: "get",
path: "/api/companies/{companyId}/tools/stdio-templates",
tags: ["tool-access"],
@@ -12074,6 +12160,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/tools/policy/test",
tags: ["tool-access"],
@@ -12136,6 +12224,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:gateway",
method: "get",
path: "/api/companies/{companyId}/tools/gateways",
tags: ["tool-gateway"],
@@ -12143,6 +12233,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:gateway",
method: "post",
path: "/api/companies/{companyId}/tools/gateways",
tags: ["tool-gateway"],
@@ -12158,6 +12250,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:gateway",
method: "patch",
path: "/api/tool-gateway/gateways/{gatewayId}",
tags: ["tool-gateway"],
@@ -12174,6 +12268,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:gateway",
method: "post",
path: "/api/tool-gateway/gateways/{gatewayId}/tokens",
tags: ["tool-gateway"],
@@ -12190,6 +12286,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:gateway",
method: "post",
path: "/api/tool-gateway/gateway-tokens/{tokenId}/revoke",
tags: ["tool-gateway"],
@@ -12545,6 +12643,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "get",
path: "/api/tool-gateway/runtime-slots",
tags: ["tool-gateway"],
@@ -12553,6 +12653,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "post",
path: "/api/tool-gateway/runtime-slots/{slotId}/stop",
tags: ["tool-gateway"],
@@ -12562,6 +12664,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:runtime",
method: "post",
path: "/api/tool-gateway/runtime-slots/{slotId}/restart",
tags: ["tool-gateway"],
@@ -12571,6 +12675,8 @@ registerCurrentRoute({
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:audit",
method: "get",
path: "/api/tool-gateway/audit",
tags: ["tool-gateway"],
@@ -12714,6 +12820,8 @@ export function openApiRoutes() {
}
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/ai-connections/local",
tags: ["ai-connections"],
@@ -12722,6 +12830,8 @@ registerCurrentRoute({
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/ai-connections/local/attempts",
tags: ["ai-connections"], summary: "Prepare an isolated local subscription sign-in",
@@ -12729,12 +12839,16 @@ registerCurrentRoute({
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "delete",
path: "/api/companies/{companyId}/ai-connections/local/attempts/{sessionId}",
tags: ["ai-connections"], summary: "Cancel an owned local subscription sign-in",
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registerCurrentRoute({
+ "x-paperclip-transport": "rest",
+ "x-paperclip-connection-management": "connections:configure",
method: "post",
path: "/api/companies/{companyId}/ai-connections/local/check",
tags: ["ai-connections"], summary: "Check an owned local subscription sign-in without saving a connection",
diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts
index 9ffebb7b9ed..7b4b3d641b7 100644
--- a/server/src/routes/tool-access.ts
+++ b/server/src/routes/tool-access.ts
@@ -58,8 +58,10 @@ import {
updateToolProfileEntrySchema,
updateToolProfileWithEntriesSchema,
} from "@paperclipai/shared";
+import { filterConnectionManagementRows, filterConnectionManagementPolicies, connectionManagementGuard, requiredConnectionUserAction, connectionRequestActor, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
+import { connectionManagementService } from "../services/connection-management.js";
import { validate } from "../middleware/validate.js";
-import { getActorInfo, assertBoard, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, hasCompanyAccess } from "./authz.js";
+import { getActorInfo, assertBoard, assertBoardOrAgent, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { accessService, logActivity, toolAccessPolicyService, toolAccessService, vercelConnectIntegrationStatus } from "../services/index.js";
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
@@ -514,22 +516,29 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
const access = accessService(db);
+ const managementGuard = connectionManagementGuard(db, "tool");
async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) {
- assertBoard(req);
+ if (req.actor.type === "agent") {
+ const context = connectionRequestContext(req);
+ if (!context || context.resource.companyId !== companyId) throw forbidden("This operation requires an authorized management context");
+ await context.reauthorize();
+ return;
+ }
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
- const userId = req.actor.userId;
+ const userId = connectionRequestUserId(req);
if (userId && await access.hasPermission(companyId, "user", userId, permissionKey)) return;
throw forbidden(`Missing permission: ${permissionKey}`);
}
function activeToolMembership(req: Request, companyId: string) {
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null;
- const membership = Array.isArray(req.actor.memberships)
- ? req.actor.memberships.find((item) => item.companyId === companyId)
+ const membership = Array.isArray((req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships))
+ ? (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.find((item) => item.companyId === companyId)
: null;
if (!membership || membership.status !== "active") {
throw forbidden("User does not have active company access");
@@ -541,13 +550,13 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
async function isToolConnectionManager(req: Request, companyId: string) {
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
- return Boolean(req.actor.userId && await access.hasPermission(
+ return Boolean(connectionRequestUserId(req) && await access.hasPermission(
companyId,
"user",
- req.actor.userId,
+ connectionRequestUserId(req)!,
"tools:manage_connections",
));
}
@@ -558,7 +567,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
) {
activeToolMembership(req, connection.companyId);
if (await isToolConnectionManager(req, connection.companyId)) return;
- if (req.actor.userId && connection.createdByUserId === req.actor.userId) return;
+ if (connectionRequestUserId(req) && connection.createdByUserId === connectionRequestUserId(req)) return;
throw forbidden(
"Only the connection creator or a connection manager can configure, reconnect, or delete this connection",
);
@@ -570,7 +579,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
) {
activeToolMembership(req, connection.companyId);
if (await isToolConnectionManager(req, connection.companyId)) return;
- if (req.actor.userId && connection.createdByUserId === req.actor.userId) return;
+ if (connectionRequestUserId(req) && connection.createdByUserId === connectionRequestUserId(req)) return;
const [grant] = await db
.select({ id: connectionGrants.id })
.from(connectionGrants)
@@ -580,8 +589,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
eq(connectionGrants.status, "active"),
or(
eq(connectionGrants.kind, "organization"),
- req.actor.userId
- ? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId))
+ connectionRequestUserId(req)
+ ? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, connectionRequestUserId(req)!))
: eq(connectionGrants.kind, "organization"),
),
))
@@ -608,8 +617,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
return { unrestricted: true, role: null, isViewer: false, isActive: true };
}
- const membership = Array.isArray(req.actor.memberships)
- ? req.actor.memberships.find((item) => item.companyId === companyId)
+ const membership = Array.isArray((req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships))
+ ? (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.find((item) => item.companyId === companyId)
: null;
const isActive = Boolean(membership && membership.status === "active");
const role = membership?.membershipRole ?? null;
@@ -617,14 +626,19 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
async function isToolConnectionManagerQuiet(req: Request, companyId: string) {
+ if (req.actor.type === "agent") return (await connectionManagementService(db).capability(req.actor, "connections:configure", {
+ ...connectionRequestContext(req)?.resource, type: "connection_management", companyId,
+ connectionId: undefined, selfServiceUserId: undefined, readOnly: false,
+ operation: connectionRequestContext(req)?.resource.operation ?? "configure",
+ })).allowed;
const membership = toolMembershipRole(req, companyId);
if (membership.unrestricted) return true;
if (!membership.isActive || membership.isViewer) return false;
if (membership.role === "owner" || membership.role === "admin") return true;
- return Boolean(req.actor.userId && await access.hasPermission(
+ return Boolean(connectionRequestUserId(req) && await access.hasPermission(
companyId,
"user",
- req.actor.userId,
+ connectionRequestUserId(req)!,
"tools:manage_connections",
));
}
@@ -655,32 +669,40 @@ function connectorEnrollmentPrincipal(req: Request): string {
const membership = toolMembershipRole(req, connection.companyId);
const isManager = await isToolConnectionManagerQuiet(req, connection.companyId);
const mutationCapable = membership.unrestricted || (membership.isActive && !membership.isViewer);
- const isCreator = Boolean(req.actor.userId && connection.createdByUserId === req.actor.userId);
- const canConfigure = isManager || (mutationCapable && isCreator);
+ const isCreator = Boolean(connectionRequestUserId(req) && connection.createdByUserId === connectionRequestUserId(req));
+ const management = connectionManagementService(db);
+ const resource = { ...connectionRequestContext(req)?.resource, type: "connection_management" as const,
+ companyId: connection.companyId, connectionId: connection.id, operation: "configure",
+ readOnly: false, selfServiceUserId: connection.createdByUserId };
+ const manage = await management.capability(req.actor, "connections:configure", resource);
+ const use = await management.capability(req.actor, "connections:test", { ...resource, operation: "use" }, { mutation: false });
+ const toolAccess = req.actor.type === "agent" && req.actor.agentId && options.toolGateway
+ ? await options.toolGateway.summarizeConnectionAccessForAgent({ companyId: connection.companyId, connectionId: connection.id,
+ agentId: req.actor.agentId, heartbeatRunId: req.actor.runId, issueId: resource.issueId, projectId: resource.projectId }) : null;
+ const usage = toolAccess ? { ...use, allowed: use.allowed && toolAccess.allowedCount > 0,
+ reason: !use.allowed ? use.reason : toolAccess.allowedCount > 0 ? "Configured actions are allowed; each call reauthorizes."
+ : toolAccess.askFirstCount > 0 ? "Configured actions require approval." : "No configured actions are available.",
+ requiresApproval: toolAccess.askFirstCount > 0, allowedCount: toolAccess.allowedCount, askFirstCount: toolAccess.askFirstCount } : use;
+ const canConfigure = manage.allowed;
const editableAgentIds: string[] = [];
if (mutationCapable) {
- const companyAgents = await db
- .select({ id: agents.id })
- .from(agents)
- .where(eq(agents.companyId, connection.companyId));
+ const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, connection.companyId));
for (const agent of companyAgents) {
- const decision = await access.decide({
- actor: req.actor,
- action: "agent_config:update",
- resource: { type: "agent", companyId: connection.companyId, agentId: agent.id },
- });
- if (decision.allowed) editableAgentIds.push(agent.id);
+ const capability = await management.capability(req.actor, "connections:profiles", { ...resource, targetAgentId: agent.id });
+ if (capability.allowed) editableAgentIds.push(agent.id);
}
}
return {
canConfigure,
+ management: manage,
+ usage,
canCreateOrganizationGrant: canConfigure,
canSetCompanyInstall: canConfigure,
// Personal consent belongs to the person: it needs a named board user, and
// it is never available to a viewer or to an unauthenticated principal.
- canConnectAsCurrentUser: Boolean(req.actor.userId) && mutationCapable,
+ canConnectAsCurrentUser: Boolean(connectionRequestUserId(req)) && mutationCapable,
canManageAgentInstalls: mutationCapable && editableAgentIds.length > 0,
- canViewOtherPersonalIdentities: isManager,
+ canViewOtherPersonalIdentities: req.actor.type === "board" && isManager,
editableAgentIds,
};
}
@@ -705,10 +727,10 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) {
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
- const userId = req.actor.userId;
+ const userId = connectionRequestUserId(req);
if (userId) {
for (const permissionKey of permissionKeys) {
if (await access.hasPermission(companyId, "user", userId, permissionKey)) return;
@@ -764,7 +786,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
await assertBoardToolPermission(req, companyId, "tools:manage_runtime");
}
- router.post("/agents/me/connections/:connectionId/start-authorization", validate(startConnectionAuthorizationSchema), async (req, res) => {
+ router.post("/agents/me/connections/:connectionId/start-authorization", managementGuard("connections:configure", "POST /agents/me/connections/:connectionId/start-authorization", { personal: true }), validate(startConnectionAuthorizationSchema), async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) {
res.status(401).json({ error: "Active agent run authentication required" });
return;
@@ -778,8 +800,9 @@ function connectorEnrollmentPrincipal(req: Request): string {
scopes: req.body.scopes,
returnTo: req.body.returnTo,
redirectUri: oauthRedirectUri(req),
+ managementContext: connectionRequestContext(req),
});
- res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) });
+ res.json({ ...requiredConnectionUserAction(req, { type: "connection", id: req.params.connectionId as string, companyId: req.actor.companyId }, result.authorizationUrl, "Authenticate the selected account and consent to the requested provider access."), url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) });
});
router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => {
@@ -807,11 +830,12 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
function assertToolAppMutationAccess(req: Request, companyId: string) {
+ if (req.actor.type === "agent" && !connectionRequestContext(req)) throw forbidden("An authorized connection management operation is required");
activeToolMembership(req, companyId);
}
- router.get("/companies/:companyId/tools/gallery", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/gallery", managementGuard("connections:read", "GET /companies/:companyId/tools/gallery", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const advertisedProfiles = options.paperclipCloudConnector === undefined
@@ -858,8 +882,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
});
- router.get("/companies/:companyId/tools/apps/:galleryKey/preflight", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/apps/:galleryKey/preflight", managementGuard("connections:read", "GET /companies/:companyId/tools/apps/:galleryKey/preflight", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const methodKey = typeof req.query.methodKey === "string" ? req.query.methodKey.trim() || null : null;
@@ -882,7 +906,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri }));
});
- router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/apps/connect", managementGuard("connections:configure", "POST /companies/:companyId/tools/apps/connect", { personal: true }), validate(connectToolAppSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
// An omitted grant kind is the backward-compatible organization default.
@@ -913,7 +937,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
);
}
try {
- const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
+ const result = await svc.connectGalleryApp(companyId, req.body, connectionRequestActor(req));
if (result.auth?.kind === "oauth") {
try {
// "Just me" must consent as the caller, not as the workspace: passing
@@ -921,12 +945,12 @@ function connectorEnrollmentPrincipal(req: Request): string {
// caller's personal grant instead of an organization grant
// (PAP-17835). The service refuses any subject other than the actor,
// so this cannot start consent on someone else's behalf.
- const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null;
+ const personalSubjectUserId = (req.actor.type === "agent" || req.body.grantKind === "user") ? connectionRequestUserId(req) ?? null : null;
const dedicatedSubjectAgentId = req.body.grantKind === "agent" ? req.body.subjectAgentId ?? null : null;
const start = await svc.startOAuth(companyId, result.connectionId, {
workspaceServices: req.body.workspaceServices,
redirectUri: oauthRedirectUri(req),
- actor: getActorInfo(req),
+ actor: connectionRequestActor(req),
...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}),
...(dedicatedSubjectAgentId ? { subjectAgentId: dedicatedSubjectAgentId } : {}),
...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}),
@@ -950,8 +974,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_app.connected",
entityType: "tool_connection",
entityId: result.connectionId,
@@ -965,38 +988,40 @@ function connectorEnrollmentPrincipal(req: Request): string {
canMakeChangesActionCount: result.actions.canMakeChanges.length,
},
});
- res.status(201).json(result);
+ res.status(201).json({ ...result, ...(result.auth?.kind === "oauth" && result.auth.startUrl
+ ? requiredConnectionUserAction(req, { type: "connection", id: result.connectionId, companyId }, result.auth.startUrl,
+ "Open this saved setup and authenticate the account in your browser.") : {}) });
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.post(
- "/companies/:companyId/tools/connections/:connectionId/start-authorization",
+ "/companies/:companyId/tools/connections/:connectionId/start-authorization", managementGuard("connections:configure", "POST /companies/:companyId/tools/connections/:connectionId/start-authorization", { personal: true }),
validate(startConnectionAuthorizationSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
activeToolMembership(req, companyId);
- if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) {
+ if (!connectionRequestUserId(req) || connectionRequestUserId(req) !== req.body.subjectUserId) {
throw forbidden("Board users may only authorize their own connection subject");
}
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
await assertToolConnectionAccess(req, existing);
const result = await svc.startOAuth(companyId, existing.id, {
redirectUri: oauthRedirectUri(req),
- actor: getActorInfo(req),
+ actor: connectionRequestActor(req),
subjectUserId: req.body.subjectUserId,
scopes: req.body.scopes,
returnTo: req.body.returnTo,
});
- res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) });
+ res.json({ ...requiredConnectionUserAction(req, { type: "connection", id: existing.id, companyId: existing.companyId }, result.authorizationUrl, "Authenticate the selected account and consent to the requested provider access."), url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) });
},
);
- router.post("/tools/oauth/:connectionId/start", validate(startToolOAuthSchema), async (req, res) => {
+ router.post("/tools/oauth/:connectionId/start", managementGuard("connections:configure", "POST /tools/oauth/:connectionId/start", { personal: true }), validate(startToolOAuthSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
- const subjectUserId = req.body?.asCurrentUser === true ? req.actor.userId ?? null : null;
+ const subjectUserId = (req.actor.type === "agent" || req.body?.asCurrentUser === true) ? connectionRequestUserId(req) ?? null : null;
const subjectAgentId = req.body?.asAgentId ?? null;
if (req.body?.asCurrentUser === true && !subjectUserId) {
throw forbidden("Connecting an app as yourself requires a signed-in user");
@@ -1013,13 +1038,13 @@ function connectorEnrollmentPrincipal(req: Request): string {
const result = await svc.startOAuth(existing.companyId, existing.id, {
workspaceServices: req.body.workspaceServices,
redirectUri: oauthRedirectUri(req),
- actor: getActorInfo(req),
+ actor: connectionRequestActor(req),
returnTo: oauthBrowserOrigin(req) ?? undefined,
...(subjectUserId ? { subjectUserId } : {}),
...(subjectAgentId ? { subjectAgentId } : {}),
...(req.body?.interactionId ? { interactionId: req.body.interactionId } : {}),
});
- res.json(result);
+ res.json({ ...result, ...requiredConnectionUserAction(req, { type: "connection", id: existing.id, companyId: existing.companyId }, result.authorizationUrl, "Authenticate the selected account and consent to the requested provider access.") });
});
router.get("/tools/oauth/cloud-connector/enrollment", async (req, res) => {
@@ -1466,17 +1491,16 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
router.post(
- "/companies/:companyId/tools/apps/:connectionId/finalize-oauth-access",
+ "/companies/:companyId/tools/apps/:connectionId/finalize-oauth-access", managementGuard("connections:configure", "POST /companies/:companyId/tools/apps/:connectionId/finalize-oauth-access", { personal: true }),
validate(finalizeOAuthAccessSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
await assertToolConnectionConfigureAccess(req, existing);
- const result = await svc.finalizeOAuthAccess(companyId, existing.id, req.body, getActorInfo(req));
+ const result = await svc.finalizeOAuthAccess(companyId, existing.id, req.body, connectionRequestActor(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_app.oauth_access_finalized",
entityType: "tool_connection",
entityId: result.connection.id,
@@ -1490,15 +1514,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
},
);
- router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/apps/:connectionId/finish", managementGuard("connections:configure", "POST /companies/:companyId/tools/apps/:connectionId/finish", { personal: true }), validate(finishToolAppSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
await assertToolConnectionConfigureAccess(req, existing);
- const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, getActorInfo(req));
+ const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, connectionRequestActor(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_app.finished",
entityType: "tool_connection",
entityId: result.connection.id,
@@ -1514,39 +1537,46 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(result);
});
- router.get("/companies/:companyId/tools/examples", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/examples", managementGuard("connections:read", "GET /companies/:companyId/tools/examples", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ examples: await svc.listExamples(companyId) });
});
- router.get("/companies/:companyId/tools/apps/attention", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/apps/attention", managementGuard("connections:read", "GET /companies/:companyId/tools/apps/attention", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- res.json(await svc.listAppsNeedingAttention(companyId));
+ const result = await svc.listAppsNeedingAttention(companyId);
+ const apps = await filterConnectionManagementRows(db, req, "connections:read", result.apps, row => ({ connectionId: row.connection.id }));
+ res.json({ ...result, apps, totals: {
+ connections: apps.length, health: apps.filter(app => app.healthNeedsAttention).length,
+ quarantinedCatalogEntries: apps.reduce((sum, app) => sum + app.quarantinedCatalogEntryCount, 0),
+ pendingActionRequests: apps.reduce((sum, app) => sum + app.pendingActionRequestCount, 0),
+ newToolsPendingReview: apps.reduce((sum, app) => sum + app.newToolsPendingReviewCount, 0),
+ newToolsPendingProfiles: new Set(apps.flatMap(app => app.newToolsPendingProfiles.map(profile => profile.profileId))).size,
+ } });
});
- router.get("/companies/:companyId/tools/action-requests", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/action-requests", managementGuard("connections:read", "GET /companies/:companyId/tools/action-requests", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const statusRaw = typeof req.query.status === "string" ? req.query.status : "pending";
const status = (TOOL_ACTION_REQUEST_STATUSES as readonly string[]).includes(statusRaw)
? (statusRaw as (typeof TOOL_ACTION_REQUEST_STATUSES)[number])
: "pending";
- res.json({ actionRequests: await svc.listActionRequests(companyId, status) });
+ res.json({ actionRequests: await filterConnectionManagementRows(db, req, "connections:read", await svc.listActionRequests(companyId, status), row => ({ connectionId: row.connectionId, issueId: row.request.issueId })) });
});
- router.post("/companies/:companyId/tools/examples/:id/install", async (req, res) => {
+ router.post("/companies/:companyId/tools/examples/:id/install", managementGuard("connections:configure", "POST /companies/:companyId/tools/examples/:id/install", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
- const result = await svc.installExample(companyId, req.params.id as string, getActorInfo(req));
+ const result = await svc.installExample(companyId, req.params.id as string, connectionRequestActor(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_example.installed",
entityType: "tool_example",
entityId: result.example.id,
@@ -1590,22 +1620,21 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(result);
});
- router.get("/companies/:companyId/tools/applications", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/applications", managementGuard("connections:read", "GET /companies/:companyId/tools/applications", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ applications: await svc.listApplications(companyId) });
});
- router.post("/companies/:companyId/tools/applications", validate(createToolApplicationSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/applications", managementGuard("connections:configure", "POST /companies/:companyId/tools/applications", { personal: true }), validate(createToolApplicationSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const application = await svc.createApplication(companyId, req.body);
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_application.created",
entityType: "tool_application",
entityId: application.id,
@@ -1617,7 +1646,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.patch("/tool-applications/:applicationId", validate(updateToolApplicationSchema), async (req, res) => {
+ router.patch("/tool-applications/:applicationId", managementGuard("connections:configure", "PATCH /tool-applications/:applicationId", { personal: true }), validate(updateToolApplicationSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
@@ -1625,8 +1654,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
const application = await svc.updateApplication(existing.id, req.body);
await logActivity(db, {
companyId: application.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_application.updated",
entityType: "tool_application",
entityId: application.id,
@@ -1638,15 +1666,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.delete("/tool-applications/:applicationId", async (req, res) => {
+ router.delete("/tool-applications/:applicationId", managementGuard("connections:configure", "DELETE /tool-applications/:applicationId", { personal: true }), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const application = await svc.deleteApplication(existing.id);
await logActivity(db, {
companyId: application.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_application.deleted",
entityType: "tool_application",
entityId: application.id,
@@ -1655,29 +1682,32 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(application);
});
- router.get("/companies/:companyId/tools/connections", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/connections", managementGuard("connections:read", "GET /companies/:companyId/tools/connections", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- const connections = await svc.listConnections(companyId, req.actor.userId);
+ const connections = await svc.listConnections(companyId, connectionRequestUserId(req) ?? undefined);
const canManageConnections = await isToolConnectionManagerQuiet(req, companyId);
- res.json({
- connections: filterVisibleToolConnections(connections, {
- userId: req.actor.userId,
- canManageConnections,
- }),
- });
+ let visible = filterVisibleToolConnections(connections, { userId: connectionRequestUserId(req), canManageConnections });
+ if (req.actor.type === "agent") {
+ const management = connectionManagementService(db);
+ const allowed = await Promise.all(visible.map(connection => management.capability(req.actor, "connections:read", {
+ type: "connection_management", companyId, connectionId: connection.id,
+ operation: "GET /companies/:companyId/tools/connections", readOnly: true,
+ }, { mutation: false })));
+ visible = visible.filter((_, index) => allowed[index].allowed);
+ }
+ res.json({ connections: visible });
});
- router.post("/companies/:companyId/tools/connections", validate(createToolConnectionSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/connections", managementGuard("connections:configure", "POST /companies/:companyId/tools/connections", { personal: true }), validate(createToolConnectionSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
- const connection = await svc.createConnection(companyId, req.body, getActorInfo(req));
+ const connection = await svc.createConnection(companyId, req.body, connectionRequestActor(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.created",
entityType: "tool_connection",
entityId: connection.id,
@@ -1695,40 +1725,40 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.get("/tool-connections/:connectionId", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId", managementGuard("connections:read", "GET /tool-connections/:connectionId", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(
req,
res,
- svc.getConnection(req.params.connectionId as string, undefined, req.actor.userId),
+ svc.getConnection(req.params.connectionId as string, undefined, connectionRequestUserId(req) ?? undefined),
"Tool connection not found",
);
if (!connection) return;
res.json(connection);
});
- router.post("/tool-connections/:connectionId/railway/ssh", validate(configureRailwaySshSchema), async (req, res) => {
- assertBoard(req);
+ router.post("/tool-connections/:connectionId/railway/ssh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/railway/ssh", { personal: true }), validate(configureRailwaySshSchema), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertToolConnectionConfigureAccess(req, connection);
- const setup = await svc.configureRailwaySsh(connection.id, connection.companyId, req.body, getActorInfo(req)).catch((error) => {
+ const setup = await svc.configureRailwaySsh(connection.id, connection.companyId, req.body, connectionRequestActor(req)).catch((error) => {
if (error instanceof RailwayError) throw new HttpError(error.status, error.message);
throw error;
});
- await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "tool_connection.railway_ssh_updated", entityType: "tool_connection", entityId: connection.id, details: { action: req.body.action, grantId: req.body.grantId, enabled: setup?.enabled ?? false } });
+ await logActivity(db, { companyId: connection.companyId, ...connectionRequestActor(req), action: "tool_connection.railway_ssh_updated", entityType: "tool_connection", entityId: connection.id, details: { action: req.body.action, grantId: req.body.grantId, enabled: setup?.enabled ?? false } });
res.json(setup);
});
- router.get("/tool-connections/:connectionId/grants", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/grants", managementGuard("connections:read", "GET /tool-connections/:connectionId/grants", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const listed = await svc.listConnectionGrants(connection.id, connection.companyId);
const capabilities = await describeConnectionCapabilities(req, connection);
const membership = toolMembershipRole(req, connection.companyId);
const grantContext = {
- userId: req.actor.userId ?? null,
+ userId: connectionRequestUserId(req) ?? null,
isManager: await isToolConnectionManagerQuiet(req, connection.companyId),
mutationCapable: membership.unrestricted || (membership.isActive && !membership.isViewer),
};
@@ -1754,10 +1784,10 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
router.put(
- "/tool-connections/:connectionId/grants/:grantId/members",
+ "/tool-connections/:connectionId/grants/:grantId/members", managementGuard("connections:profiles", "PUT /tool-connections/:connectionId/grants/:grantId/members", { personal: true }),
validate(replaceConnectionGrantMembersSchema),
async (req, res) => {
- assertBoard(req);
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
// Viewer/inactive principals are rejected before anything else, so a
@@ -1766,19 +1796,18 @@ function connectorEnrollmentPrincipal(req: Request): string {
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
const target = grants.find((grant) => grant.id === req.params.grantId);
if (!target) throw notFound("Connection grant not found");
- const isGrantCreator = Boolean(req.actor.userId && target.createdByUserId === req.actor.userId);
+ const isGrantCreator = Boolean(connectionRequestUserId(req) && target.createdByUserId === connectionRequestUserId(req));
if (!isGrantCreator) await assertToolConnectionConfigureAccess(req, connection);
const memberUserIds = req.body.memberUserIds as string[];
const grant = await svc.replaceConnectionGrantMembers(
connection.id,
target.id,
memberUserIds,
- getActorInfo(req),
+ connectionRequestActor(req),
);
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.grant_audience_replaced",
entityType: "connection_grant",
entityId: grant.id,
@@ -1788,8 +1817,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
},
);
- router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => {
- assertBoard(req);
+ router.post("/tool-connections/:connectionId/grants/installations", managementGuard("connections:profiles", "POST /tool-connections/:connectionId/grants/installations", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertToolConnectionConfigureAccess(req, connection);
@@ -1802,11 +1831,10 @@ function connectorEnrollmentPrincipal(req: Request): string {
providerTenant,
credentialSecretRefs,
isDefault: body.isDefault === true,
- }, getActorInfo(req));
+ }, connectionRequestActor(req));
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.grant_added",
entityType: "connection_grant",
entityId: grant.id,
@@ -1815,23 +1843,22 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.status(201).json(grant);
});
- router.delete("/tool-connections/:connectionId/grants/:grantId", async (req, res) => {
- assertBoard(req);
+ router.delete("/tool-connections/:connectionId/grants/:grantId", managementGuard("connections:configure", "DELETE /tool-connections/:connectionId/grants/:grantId", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
const grantToRevoke = grants.find((grant) => grant.id === req.params.grantId);
const canRevokeOwnGrant = Boolean(
- req.actor.userId
+ connectionRequestUserId(req)
&& grantToRevoke
- && (grantToRevoke.subjectUserId === req.actor.userId || grantToRevoke.createdByUserId === req.actor.userId),
+ && (grantToRevoke.subjectUserId === connectionRequestUserId(req) || grantToRevoke.createdByUserId === connectionRequestUserId(req)),
);
if (!canRevokeOwnGrant) await assertToolConnectionConfigureAccess(req, connection);
- const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req));
+ const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, connectionRequestActor(req));
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.grant_revoked",
entityType: "connection_grant",
entityId: grant.id,
@@ -1840,11 +1867,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(grant);
});
- router.post("/tool-connections/:connectionId/grants/:grantId/delegations", validate(createConnectionGrantDelegationSchema), async (req, res) => {
- assertBoard(req);
+ router.post("/tool-connections/:connectionId/grants/:grantId/delegations", managementGuard("connections:profiles", "POST /tool-connections/:connectionId/grants/:grantId/delegations", { personal: true }), validate(createConnectionGrantDelegationSchema), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
- const ownerUserId = req.actor.userId;
+ const ownerUserId = connectionRequestUserId(req);
if (!ownerUserId) throw forbidden("A named user is required to delegate a personal grant");
const agentId = req.body.agentId;
const delegation = await svc.createConnectionGrantDelegation(
@@ -1852,11 +1879,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
req.params.grantId as string,
agentId,
ownerUserId,
+ connectionRequestContext(req),
);
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: ownerUserId,
+ ...connectionRequestActor(req),
action: "tool_connection.grant_delegated",
entityType: "connection_grant",
entityId: req.params.grantId as string,
@@ -1865,14 +1892,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.status(201).json(delegation);
});
- router.delete("/tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", async (req, res) => {
- assertBoard(req);
+ router.delete("/tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", managementGuard("connections:profiles", "DELETE /tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
const grant = grants.find((candidate) => candidate.id === req.params.grantId);
if (!grant) throw notFound("Connection grant not found");
- const canRevokeOwnDelegation = Boolean(req.actor.userId && grant.subjectUserId === req.actor.userId);
+ const canRevokeOwnDelegation = Boolean(connectionRequestUserId(req) && grant.subjectUserId === connectionRequestUserId(req));
if (!canRevokeOwnDelegation && !await isToolConnectionManager(req, connection.companyId)) {
throw forbidden("Only the personal grant owner or a connection manager can revoke a delegation");
}
@@ -1880,12 +1907,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
connection.id,
grant.id,
req.params.delegationId as string,
- getActorInfo(req),
+ connectionRequestActor(req),
);
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.grant_delegation_revoked",
entityType: "connection_grant",
entityId: grant.id,
@@ -1894,8 +1920,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(delegation);
});
- router.get("/tool-connections/:connectionId/usage", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/usage", managementGuard("connections:read", "GET /tool-connections/:connectionId/usage", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const range = req.query.range === "30d" ? "30d" : req.query.range === undefined || req.query.range === "7d" ? "7d" : null;
@@ -1903,18 +1929,18 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(await svc.getConnectionUsage(connection.id, range, connection.companyId));
});
- router.get("/tool-connections/:connectionId/installs", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/installs", managementGuard("connections:profiles", "GET /tool-connections/:connectionId/installs", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
res.json({ connectionId: connection.id, installs: connection.installs ?? [] });
});
router.put(
- "/tool-connections/:connectionId/installs",
+ "/tool-connections/:connectionId/installs", managementGuard("connections:profiles", "PUT /tool-connections/:connectionId/installs", { personal: true }),
validate(putToolConnectionInstallsSchema),
async (req, res) => {
- assertBoard(req);
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const existingInstalls = await db
@@ -1958,11 +1984,10 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
}
}
- const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req));
+ const snapshot = await svc.putConnectionInstalls(connection.id, req.body, connectionRequestActor(req));
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.installs_synced",
entityType: "tool_connection",
entityId: connection.id,
@@ -1974,8 +1999,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
},
);
- router.get("/tool-connections/:connectionId/test-agents", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/test-agents", managementGuard("connections:read", "GET /tool-connections/:connectionId/test-agents", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
@@ -2015,8 +2040,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json({ agents: candidates });
});
- router.get("/tool-connections/:connectionId/test-agents/:agentId/access", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/test-agents/:agentId/access", managementGuard("connections:read", "GET /tool-connections/:connectionId/test-agents/:agentId/access", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
@@ -2035,69 +2060,69 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
async function composioAppManager(req: Request, res: Response) {
- assertBoard(req);
+ assertBoardOrAgent(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return null;
if (!await isToolConnectionManager(req, connection.companyId)) throw forbidden("Only a connection manager can configure apps and grant agent access");
return connection;
}
- router.get("/tool-connections/:connectionId/aggregator/apps", async (req, res) => {
+ router.get("/tool-connections/:connectionId/aggregator/apps", managementGuard("connections:read", "GET /tool-connections/:connectionId/aggregator/apps", { personal: true }), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
res.set("Cache-Control", "private, no-store");
- res.json(await svc.listAggregatorApps(connection.id, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.listAggregatorApps(connection.id, { ...connectionRequestActor(req), }));
});
- router.post("/tool-connections/:connectionId/aggregator/apps/sync", validate(aggregatorAppsSyncSchema), async (req, res) => {
+ router.post("/tool-connections/:connectionId/aggregator/apps/sync", managementGuard("connections:configure", "POST /tool-connections/:connectionId/aggregator/apps/sync", { personal: true }), validate(aggregatorAppsSyncSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.syncAggregatorApps(connection.id, req.body.force, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.syncAggregatorApps(connection.id, req.body.force, { ...connectionRequestActor(req), }));
});
- router.post("/tool-connections/:connectionId/aggregator/apps/refresh", validate(aggregatorAppsRefreshSchema), async (req, res) => {
+ router.post("/tool-connections/:connectionId/aggregator/apps/refresh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/aggregator/apps/refresh", { personal: true }), validate(aggregatorAppsRefreshSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.refreshAggregatorApps(connection.id, req.body.toolkits, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.refreshAggregatorApps(connection.id, req.body.toolkits, { ...connectionRequestActor(req), }));
});
- router.put("/tool-connections/:connectionId/aggregator/discovery", validate(arcadeDiscoverySetupSchema), async (req, res) => {
+ router.put("/tool-connections/:connectionId/aggregator/discovery", managementGuard("connections:configure", "PUT /tool-connections/:connectionId/aggregator/discovery", { personal: true }), validate(arcadeDiscoverySetupSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.configureArcadeDiscovery(connection.id, req.body, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.configureArcadeDiscovery(connection.id, req.body, { ...connectionRequestActor(req), }));
});
- router.get("/tool-connections/:connectionId/composio/apps", async (req, res) => {
+ router.get("/tool-connections/:connectionId/composio/apps", managementGuard("connections:read", "GET /tool-connections/:connectionId/composio/apps", { personal: true }), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.listComposioApps(connection.id, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.listComposioApps(connection.id, { ...connectionRequestActor(req), }));
});
- router.post("/tool-connections/:connectionId/composio/apps/sync", validate(composioAppsSyncSchema), async (req, res) => {
+ router.post("/tool-connections/:connectionId/composio/apps/sync", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/sync", { personal: true }), validate(composioAppsSyncSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.syncComposioApps(connection.id, req.body.force, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.syncComposioApps(connection.id, req.body.force, { ...connectionRequestActor(req), }));
});
- router.post("/tool-connections/:connectionId/composio/apps/refresh", validate(composioAppsRefreshSchema), async (req, res) => {
+ router.post("/tool-connections/:connectionId/composio/apps/refresh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/refresh", { personal: true }), validate(composioAppsRefreshSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.refreshComposioApps(connection.id, req.body.toolkits, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.refreshComposioApps(connection.id, req.body.toolkits, { ...connectionRequestActor(req), }));
});
- router.post("/tool-connections/:connectionId/composio/apps/:toolkit/accounts", validate(composioAppAccountSchema), async (req, res) => {
+ router.post("/tool-connections/:connectionId/composio/apps/:toolkit/accounts", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/:toolkit/accounts", { personal: true }), validate(composioAppAccountSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
- res.json(await svc.manageComposioAppAccount(connection.id, req.params.toolkit as string, req.body, { actorType: "user", actorId: req.actor.userId ?? "board" }));
+ res.json(await svc.manageComposioAppAccount(connection.id, req.params.toolkit as string, req.body, { ...connectionRequestActor(req), }));
});
- router.post("/tool-connections/:connectionId/composio/apps/:toolkit/setup", validate(composioAppSetupSchema), async (req, res) => {
+ router.post("/tool-connections/:connectionId/composio/apps/:toolkit/setup", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/:toolkit/setup", { personal: true }), validate(composioAppSetupSchema), async (req, res) => {
const connection = await composioAppManager(req, res);
if (!connection) return;
const result = await svc.setupComposioApp(connection.id, req.params.toolkit as string, req.body,
- { actorType: "user", actorId: req.actor.userId ?? "board" });
+ { ...connectionRequestActor(req), });
res.json(result);
});
- router.post("/tool-connections/:connectionId/test-calls", validate(toolConnectionTestCallSchema), async (req, res) => {
- assertBoard(req);
+ router.post("/tool-connections/:connectionId/test-calls", managementGuard("connections:test", "POST /tool-connections/:connectionId/test-calls", { personal: true }), validate(toolConnectionTestCallSchema), async (req, res) => {
+ assertBoardOrAgent(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
@@ -2108,10 +2133,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
await assertCanTestAsAgent(req, connection.companyId, req.body.agentId);
try {
const result = await options.toolGateway.executeTestCall({
+ managementContext: connectionRequestContext(req),
companyId: connection.companyId,
connectionId: connection.id,
agentId: req.body.agentId,
- userId: req.actor.userId ?? "board",
+ userId: connectionRequestUserId(req) ?? "board",
toolName: req.body.toolName,
parameters: req.body.parameters ?? {},
});
@@ -2121,8 +2147,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.get("/tool-connections/:connectionId/test-calls/:actionRequestId", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/test-calls/:actionRequestId", managementGuard("connections:test", "GET /tool-connections/:connectionId/test-calls/:actionRequestId", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
@@ -2142,7 +2168,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => {
+ router.patch("/tool-connections/:connectionId", managementGuard("connections:configure", "PATCH /tool-connections/:connectionId", { personal: true }), validate(updateToolConnectionSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
@@ -2154,8 +2180,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
);
const baseLog = {
companyId: connection.companyId,
- actorType: "user" as const,
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.updated",
entityType: "tool_connection",
entityId: connection.id,
@@ -2189,7 +2214,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(connection);
});
- router.delete("/tool-connections/:connectionId", async (req, res) => {
+ router.delete("/tool-connections/:connectionId", managementGuard("connections:configure", "DELETE /tool-connections/:connectionId", { personal: true }), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
@@ -2198,7 +2223,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
const { connection, removal } = await svc.archiveConnection(
existing.id,
existing.companyId,
- getActorInfo(req),
+ connectionRequestActor(req),
);
const applicationAfter = await svc.getApplication(existing.applicationId);
// The receipt is counts and outcomes only. Removal is a revocation boundary
@@ -2206,8 +2231,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
// company-readable activity, so it never carries a secret name or value.
await logActivity(db, {
companyId: connection.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.archived",
entityType: "tool_connection",
entityId: connection.id,
@@ -2216,8 +2240,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
if (applicationBefore.status !== "archived" && applicationAfter.status === "archived") {
await logActivity(db, {
companyId: applicationAfter.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_application.archived",
entityType: "tool_application",
entityId: applicationAfter.id,
@@ -2227,16 +2250,16 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json({ ...connection, removal });
});
- router.post("/tool-connections/:connectionId/health-check", async (req, res) => {
+ router.post("/tool-connections/:connectionId/health-check", managementGuard("connections:configure", "POST /tool-connections/:connectionId/health-check", { personal: true }), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
if (existing.credentialPolicy === "per_user") await assertToolConnectionAccess(req, existing);
else await assertToolConnectionConfigureAccess(req, existing);
- res.json(await svc.checkHealth(existing.id, getActorInfo(req)));
+ res.json(await svc.checkHealth(existing.id, connectionRequestActor(req)));
});
router.post(
- "/tool-connections/:connectionId/reconnect",
+ "/tool-connections/:connectionId/reconnect", managementGuard("connections:configure", "POST /tool-connections/:connectionId/reconnect", { personal: true }),
validate(reconnectToolAppSchema),
async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
@@ -2246,12 +2269,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
existing.id,
existing.companyId,
req.body,
- getActorInfo(req),
+ connectionRequestActor(req),
);
await logActivity(db, {
companyId: existing.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_app.reconnected",
entityType: "tool_connection",
entityId: existing.id,
@@ -2261,15 +2283,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
},
);
- router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => {
+ router.post("/tool-connections/:connectionId/catalog/refresh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/catalog/refresh", { personal: true }), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
- const result = await svc.refreshCatalog(existing.id, getActorInfo(req));
+ const result = await svc.refreshCatalog(existing.id, connectionRequestActor(req));
await logActivity(db, {
companyId: existing.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_connection.catalog_refresh",
entityType: "tool_connection",
entityId: existing.id,
@@ -2281,45 +2302,44 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(result);
});
- router.get("/tool-connections/:connectionId/catalog", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/catalog", managementGuard("connections:read", "GET /tool-connections/:connectionId/catalog", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
res.json({ catalog: await svc.listCatalog(existing.id, existing.companyId, getActorInfo(req)) });
});
- router.get("/tool-connections/:connectionId/activity", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-connections/:connectionId/activity", managementGuard("connections:audit", "GET /tool-connections/:connectionId/activity", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
const limitRaw = Number(req.query.limit ?? 20);
const limit = Number.isFinite(limitRaw) ? limitRaw : 20;
- res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit));
+ res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit, connectionRequestContext(req)));
});
- router.get("/companies/:companyId/tools/profiles", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/profiles", managementGuard("connections:profiles", "GET /companies/:companyId/tools/profiles", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- res.json({ profiles: await svc.listProfiles(companyId) });
+ res.json({ profiles: await filterConnectionManagementRows(db, req, "connections:profiles", await svc.listProfiles(companyId), row => ({ profileId: row.id })) });
});
- router.get("/tool-profiles/:profileId/new-tools", async (req, res) => {
- assertBoard(req);
+ router.get("/tool-profiles/:profileId/new-tools", managementGuard("connections:profiles", "GET /tool-profiles/:profileId/new-tools", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
res.json(await svc.listProfileNewTools(existing.id, existing.companyId));
});
- router.post("/companies/:companyId/tools/profiles", validate(createToolProfileWithEntriesSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/profiles", managementGuard("connections:profiles", "POST /companies/:companyId/tools/profiles", { personal: true }), validate(createToolProfileWithEntriesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
- const profile = await svc.createProfile(companyId, req.body);
+ const profile = await svc.createProfile(companyId, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile.created",
entityType: "tool_profile",
entityId: profile.id,
@@ -2331,23 +2351,22 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.get("/companies/:companyId/tools/profiles/effective/agents/:agentId", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/profiles/effective/agents/:agentId", managementGuard("connections:profiles", "GET /companies/:companyId/tools/profiles/effective/agents/:agentId", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string));
});
- router.patch("/tool-profiles/:profileId", validate(updateToolProfileWithEntriesSchema), async (req, res) => {
+ router.patch("/tool-profiles/:profileId", managementGuard("connections:profiles", "PATCH /tool-profiles/:profileId", { personal: true }), validate(updateToolProfileWithEntriesSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
try {
- const profile = await svc.updateProfile(existing.id, req.body);
+ const profile = await svc.updateProfile(existing.id, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId: profile.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile.updated",
entityType: "tool_profile",
entityId: profile.id,
@@ -2359,16 +2378,15 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.post("/tool-profiles/:profileId/duplicate", validate(duplicateToolProfileSchema), async (req, res) => {
+ router.post("/tool-profiles/:profileId/duplicate", managementGuard("connections:profiles", "POST /tool-profiles/:profileId/duplicate", { personal: true }), validate(duplicateToolProfileSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
try {
- const profile = await svc.duplicateProfile(existing.id, req.body);
+ const profile = await svc.duplicateProfile(existing.id, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId: profile.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile.duplicated",
entityType: "tool_profile",
entityId: profile.id,
@@ -2385,15 +2403,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.delete("/tool-profiles/:profileId", validate(deleteToolProfileSchema), async (req, res) => {
+ router.delete("/tool-profiles/:profileId", managementGuard("connections:profiles", "DELETE /tool-profiles/:profileId", { personal: true }), validate(deleteToolProfileSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
- const result = await svc.deleteProfile(existing.id, req.body);
+ const result = await svc.deleteProfile(existing.id, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId: existing.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile.deleted",
entityType: "tool_profile",
entityId: existing.id,
@@ -2407,15 +2424,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(result);
});
- router.post("/tool-profiles/:profileId/new-tools/review", validate(reviewToolProfileNewToolsSchema), async (req, res) => {
+ router.post("/tool-profiles/:profileId/new-tools/review", managementGuard("connections:profiles", "POST /tool-profiles/:profileId/new-tools/review", { personal: true }), validate(reviewToolProfileNewToolsSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
- const result = await svc.reviewProfileNewTools(existing.id, req.body, getActorInfo(req));
+ const result = await svc.reviewProfileNewTools(existing.id, req.body, connectionRequestActor(req));
await logActivity(db, {
companyId: existing.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile.new_tools_reviewed",
entityType: "tool_profile",
entityId: existing.id,
@@ -2428,15 +2444,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(result);
});
- router.post("/tool-profiles/:profileId/entries", validate(createToolProfileEntryForProfileSchema), async (req, res) => {
+ router.post("/tool-profiles/:profileId/entries", managementGuard("connections:profiles", "POST /tool-profiles/:profileId/entries", { personal: true }), validate(createToolProfileEntryForProfileSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
- const entry = await svc.addProfileEntry(existing.id, req.body);
+ const entry = await svc.addProfileEntry(existing.id, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId: entry.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile_entry.created",
entityType: "tool_profile_entry",
entityId: entry.id,
@@ -2445,15 +2460,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.status(201).json(entry);
});
- router.patch("/tool-profile-entries/:entryId", validate(updateToolProfileEntrySchema), async (req, res) => {
+ router.patch("/tool-profile-entries/:entryId", managementGuard("connections:profiles", "PATCH /tool-profile-entries/:entryId", { personal: true }), validate(updateToolProfileEntrySchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
- const entry = await svc.updateProfileEntry(existing.id, req.body);
+ const entry = await svc.updateProfileEntry(existing.id, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId: entry.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile_entry.updated",
entityType: "tool_profile_entry",
entityId: entry.id,
@@ -2462,15 +2476,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(entry);
});
- router.delete("/tool-profile-entries/:entryId", async (req, res) => {
+ router.delete("/tool-profile-entries/:entryId", managementGuard("connections:profiles", "DELETE /tool-profile-entries/:entryId", { personal: true }), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
- const entry = await svc.deleteProfileEntry(existing.id);
+ const entry = await svc.deleteProfileEntry(existing.id, connectionRequestContext(req));
await logActivity(db, {
companyId: entry.companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile_entry.deleted",
entityType: "tool_profile_entry",
entityId: entry.id,
@@ -2480,18 +2493,17 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
router.post(
- "/companies/:companyId/tools/profiles/:profileId/bind",
+ "/companies/:companyId/tools/profiles/:profileId/bind", managementGuard("connections:profiles", "POST /companies/:companyId/tools/profiles/:profileId/bind", { personal: true }),
validate(createToolProfileBindingForProfileSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const existing = await svc.getProfile(req.params.profileId as string, companyId);
try {
- const binding = await svc.bindProfile(existing.id, req.body, getActorInfo(req));
+ const binding = await svc.bindProfile(existing.id, req.body, connectionRequestActor(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile_binding.created",
entityType: "tool_profile_binding",
entityId: binding.id,
@@ -2505,17 +2517,16 @@ function connectorEnrollmentPrincipal(req: Request): string {
);
router.post(
- "/companies/:companyId/tools/profiles/:profileId/unbind",
+ "/companies/:companyId/tools/profiles/:profileId/unbind", managementGuard("connections:profiles", "POST /companies/:companyId/tools/profiles/:profileId/unbind", { personal: true }),
validate(unbindToolProfileBindingSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const existing = await svc.getProfile(req.params.profileId as string, companyId);
- const result = await svc.unbindProfile(existing.id, req.body);
+ const result = await svc.unbindProfile(existing.id, req.body, connectionRequestContext(req));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_profile_binding.deleted",
entityType: "tool_profile",
entityId: existing.id,
@@ -2525,62 +2536,62 @@ function connectorEnrollmentPrincipal(req: Request): string {
},
);
- router.get("/companies/:companyId/tools/runtime-slots", async (req, res) => {
+ router.get("/companies/:companyId/tools/runtime-slots", managementGuard("connections:runtime", "GET /companies/:companyId/tools/runtime-slots", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsRuntimeManage(req, companyId);
- res.json({ runtimeSlots: await svc.listRuntimeSlots(companyId) });
+ res.json({ runtimeSlots: await filterConnectionManagementRows(db, req, "connections:runtime", await svc.listRuntimeSlots(companyId), row => ({ connectionId: row.connectionId, issueId: row.issueId })) });
});
- router.post("/companies/:companyId/tools/runtime-slots/:id/stop", async (req, res) => {
+ router.post("/companies/:companyId/tools/runtime-slots/:id/stop", managementGuard("connections:runtime", "POST /companies/:companyId/tools/runtime-slots/:id/stop", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsRuntimeManage(req, companyId);
- res.json(await svc.stopRuntimeSlot(companyId, req.params.id as string, getActorInfo(req)));
+ res.json(await svc.stopRuntimeSlot(companyId, req.params.id as string, connectionRequestActor(req)));
});
- router.post("/companies/:companyId/tools/runtime-slots/:id/restart", async (req, res) => {
+ router.post("/companies/:companyId/tools/runtime-slots/:id/restart", managementGuard("connections:runtime", "POST /companies/:companyId/tools/runtime-slots/:id/restart", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsRuntimeManage(req, companyId);
- res.json(await svc.restartRuntimeSlot(companyId, req.params.id as string, getActorInfo(req)));
+ res.json(await svc.restartRuntimeSlot(companyId, req.params.id as string, connectionRequestActor(req)));
});
- router.get("/companies/:companyId/tools/runtime-health", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/runtime-health", managementGuard("connections:runtime", "GET /companies/:companyId/tools/runtime-health", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.getRuntimeHealth(companyId));
});
- router.get("/companies/:companyId/tools/runs/:runId/decisions", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/runs/:runId/decisions", managementGuard("connections:audit", "GET /companies/:companyId/tools/runs/:runId/decisions", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- res.json(await svc.getRunDecisionLookup(companyId, req.params.runId as string));
+ const result = await svc.getRunDecisionLookup(companyId, req.params.runId as string);
+ res.json({ ...result, decisions: await filterConnectionManagementRows(db, req, "connections:audit", result.decisions, row => ({ connectionId: row.invocation.connectionId, issueId: row.invocation.issueId })) });
});
- router.get("/companies/:companyId/tools/trust-rules", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/trust-rules", managementGuard("connections:profiles", "GET /companies/:companyId/tools/trust-rules", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- res.json({ trustRules: await policySvc.listTrustRules(companyId) });
+ res.json({ trustRules: await filterConnectionManagementPolicies(db, req, await policySvc.listTrustRules(companyId)) });
});
- router.get("/companies/:companyId/tools/policies", async (req, res) => {
- assertBoard(req);
+ router.get("/companies/:companyId/tools/policies", managementGuard("connections:profiles", "GET /companies/:companyId/tools/policies", { personal: true }), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- res.json({ policies: await policySvc.listPolicies(companyId) });
+ res.json({ policies: await filterConnectionManagementPolicies(db, req, await policySvc.listPolicies(companyId)) });
});
// Rules UI sentence slots map exactly onto policy selectors:
// capability -> riskLevel, app -> applicationId, actions -> toolNames.
- router.post("/companies/:companyId/tools/policies/reorder", validate(reorderToolPoliciesSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/policies/reorder", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies/reorder", { personal: true }), validate(reorderToolPoliciesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policies = await policySvc.reorderPolicies(companyId, req.body);
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_policy.reordered",
entityType: "tool_policy",
entityId: companyId,
@@ -2592,15 +2603,14 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json({ policies });
});
- router.post("/companies/:companyId/tools/policies", validate(createToolPolicySchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/policies", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies", { personal: true }), validate(createToolPolicySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
- const policy = await policySvc.createPolicy(companyId, req.body, { userId: req.actor.userId ?? null });
+ const policy = await policySvc.createPolicy(companyId, req.body, { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null });
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_policy.created",
entityType: "tool_policy",
entityId: policy.id,
@@ -2612,7 +2622,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.post("/companies/:companyId/tools/policies/:policyId/duplicate", validate(duplicateToolPolicySchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/policies/:policyId/duplicate", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies/:policyId/duplicate", { personal: true }), validate(duplicateToolPolicySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
@@ -2620,12 +2630,11 @@ function connectorEnrollmentPrincipal(req: Request): string {
companyId,
policyId: req.params.policyId as string,
body: req.body,
- actor: { userId: req.actor.userId ?? null },
+ actor: { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null },
});
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_policy.duplicated",
entityType: "tool_policy",
entityId: policy.id,
@@ -2642,7 +2651,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.patch("/companies/:companyId/tools/policies/:policyId", validate(updateToolPolicySchema), async (req, res) => {
+ router.patch("/companies/:companyId/tools/policies/:policyId", managementGuard("connections:profiles", "PATCH /companies/:companyId/tools/policies/:policyId", { personal: true }), validate(updateToolPolicySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
@@ -2653,8 +2662,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_policy.updated",
entityType: "tool_policy",
entityId: policy.id,
@@ -2666,7 +2674,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
}
});
- router.delete("/companies/:companyId/tools/policies/:policyId", async (req, res) => {
+ router.delete("/companies/:companyId/tools/policies/:policyId", managementGuard("connections:profiles", "DELETE /companies/:companyId/tools/policies/:policyId", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policy = await policySvc.deletePolicy({
@@ -2675,8 +2683,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
});
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_policy.deleted",
entityType: "tool_policy",
entityId: policy.id,
@@ -2714,19 +2721,18 @@ function connectorEnrollmentPrincipal(req: Request): string {
},
);
- router.post("/companies/:companyId/tools/trust-rules/:policyId/revoke", validate(revokeToolTrustRuleSchema), async (req, res) => {
+ router.post("/companies/:companyId/tools/trust-rules/:policyId/revoke", managementGuard("connections:profiles", "POST /companies/:companyId/tools/trust-rules/:policyId/revoke", { personal: true }), validate(revokeToolTrustRuleSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policy = await policySvc.revokeTrustRule({
companyId,
policyId: req.params.policyId as string,
body: req.body,
- actor: { userId: req.actor.userId ?? null },
+ actor: { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null },
});
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: req.actor.userId ?? "board",
+ ...connectionRequestActor(req),
action: "tool_trust_rule.revoked",
entityType: "tool_policy",
entityId: policy.id,
@@ -2735,7 +2741,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(policy);
});
- router.get("/companies/:companyId/tools/stdio-templates", async (req, res) => {
+ router.get("/companies/:companyId/tools/stdio-templates", managementGuard("connections:read", "GET /companies/:companyId/tools/stdio-templates"), async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsAdmin(req, companyId);
res.json({ templates: await svc.approvedStdioTemplates(companyId) });
@@ -2800,11 +2806,12 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(preview);
});
- router.post("/companies/:companyId/tools/policy/test", validate(toolPolicyTestRequestSchema), async (req, res) => {
- assertBoard(req);
+ router.post("/companies/:companyId/tools/policy/test", managementGuard("connections:configure", "POST /companies/:companyId/tools/policy/test", { personal: true }), validate(toolPolicyTestRequestSchema), async (req, res) => {
+ assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
- const input = { ...req.body, companyId };
+ const input = { ...req.body, companyId,
+ ...(req.actor.type === "agent" ? { actorType: "agent", actorId: req.actor.agentId } : {}) };
const decision = await policySvc.decide(input);
let auditEvent = null;
if (input.writeAuditEvent === true) {
diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts
index 91becb88bf3..9dd4fb9425e 100644
--- a/server/src/routes/tool-gateway.ts
+++ b/server/src/routes/tool-gateway.ts
@@ -1,7 +1,8 @@
+import { filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { Router, type Request, type Response } from "express";
import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
-import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db";
+import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations, toolRuntimeSlots } from "@paperclipai/db";
import {
humanizeConnectionDisplayName,
type PermissionKey,
@@ -349,13 +350,14 @@ function sendGatewayError(res: import("express").Response, err: unknown) {
export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const router = Router();
+ const managementGuard = connectionManagementGuard(db, "tool");
const access = accessService(db);
async function assertBoardPermission(req: import("express").Request, companyId: string, permissionKey: PermissionKey) {
- assertBoard(req);
+ assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
- if (req.actor.userId && await access.canUser(companyId, req.actor.userId, permissionKey)) return;
+ if (connectionRequestUserId(req) && await access.canUser(companyId, connectionRequestUserId(req)!, permissionKey)) return;
throw forbidden(`Missing permission: ${permissionKey}`);
}
@@ -374,18 +376,18 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
}
- router.get("/companies/:companyId/tools/gateways", async (req, res) => {
+ router.get("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "GET /companies/:companyId/tools/gateways"), async (req, res) => {
try {
- await assertBoardPermission(req, req.params.companyId, "tools:admin");
- res.json({ gateways: await toolGateway.listNamedGateways(req.params.companyId) });
+ await assertBoardPermission(req, (req.params.companyId as string), "tools:admin");
+ res.json({ gateways: await filterConnectionManagementRows(db, req, "connections:gateway", await toolGateway.listNamedGateways((req.params.companyId as string)), row => ({ gatewayId: row.id })) });
} catch (err) {
sendGatewayError(res, err);
}
});
- router.post("/companies/:companyId/tools/gateways", async (req, res) => {
+ router.post("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "POST /companies/:companyId/tools/gateways"), async (req, res) => {
try {
- await assertBoardPermission(req, req.params.companyId, "tools:admin");
+ await assertBoardPermission(req, (req.params.companyId as string), "tools:admin");
const parsed = createToolMcpGatewaySchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(422).json({ error: "Invalid gateway payload", issues: parsed.error.issues });
@@ -393,9 +395,10 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
const actor = getActorInfo(req);
const gateway = await toolGateway.createNamedGateway({
- companyId: req.params.companyId,
+ companyId: (req.params.companyId as string),
body: parsed.data,
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
+ managementContext: connectionRequestContext(req),
});
res.status(201).json(gateway);
} catch (err) {
@@ -403,9 +406,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
- router.patch("/tool-gateway/gateways/:gatewayId", async (req, res) => {
+ router.patch("/tool-gateway/gateways/:gatewayId", managementGuard("connections:gateway", "PATCH /tool-gateway/gateways/:gatewayId"), async (req, res) => {
try {
- assertBoard(req);
+ assertBoardOrAgent(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
@@ -418,15 +421,15 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
return;
}
const { companyId: _companyId, ...body } = parsed.data as typeof parsed.data & { companyId?: string };
- res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: req.params.gatewayId, body }));
+ res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: (req.params.gatewayId as string), body, managementContext: connectionRequestContext(req) }));
} catch (err) {
sendGatewayError(res, err);
}
});
- router.post("/tool-gateway/gateways/:gatewayId/tokens", async (req, res) => {
+ router.post("/tool-gateway/gateways/:gatewayId/tokens", managementGuard("connections:gateway", "POST /tool-gateway/gateways/:gatewayId/tokens"), async (req, res) => {
try {
- assertBoard(req);
+ assertBoardOrAgent(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
@@ -441,25 +444,26 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const actor = getActorInfo(req);
res.status(201).json(await toolGateway.createNamedGatewayToken({
companyId,
- gatewayId: req.params.gatewayId,
+ gatewayId: (req.params.gatewayId as string),
body: parsed.data,
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
+ managementContext: connectionRequestContext(req),
}));
} catch (err) {
sendGatewayError(res, err);
}
});
- router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", async (req, res) => {
+ router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", managementGuard("connections:gateway", "POST /tool-gateway/gateway-tokens/:tokenId/revoke"), async (req, res) => {
try {
- assertBoard(req);
+ assertBoardOrAgent(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
return;
}
await assertBoardPermission(req, companyId, "tools:admin");
- res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: req.params.tokenId }));
+ res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: (req.params.tokenId as string), managementContext: connectionRequestContext(req) }));
} catch (err) {
sendGatewayError(res, err);
}
@@ -470,7 +474,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
});
router.post("/tool-gateway/gateways/:gatewayId/mcp", async (req, res) => {
- await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: req.params.gatewayId });
+ await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: (req.params.gatewayId as string) });
});
router.post("/tool-gateway/sessions", async (req, res) => {
@@ -659,22 +663,25 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
- router.get("/tool-gateway/runtime-slots", async (req, res) => {
+ router.get("/tool-gateway/runtime-slots", managementGuard("connections:runtime", "GET /tool-gateway/runtime-slots"), async (req, res) => {
try {
- assertBoard(req);
+ assertBoardOrAgent(req);
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
return;
}
await assertBoardPermission(req, companyId, "tools:manage_runtime");
- res.json(await toolGateway.listRuntimeSlots(companyId));
+ const slots = await toolGateway.listRuntimeSlots(companyId);
+ const resources = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.companyId, companyId));
+ const byId = new Map(resources.map(row => [row.id, row]));
+ res.json(await filterConnectionManagementRows(db, req, "connections:runtime", slots.filter(row => byId.has(row.id)), row => ({ connectionId: byId.get(row.id)!.connectionId, issueId: byId.get(row.id)!.issueId })));
} catch (err) {
sendGatewayError(res, err);
}
});
- router.post("/tool-gateway/runtime-slots/:slotId/stop", async (req, res) => {
+ router.post("/tool-gateway/runtime-slots/:slotId/stop", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/stop"), async (req, res) => {
try {
const companyId =
typeof req.body?.companyId === "string"
@@ -690,7 +697,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const actor = getActorInfo(req);
res.json(await toolGateway.stopRuntimeSlot({
companyId,
- slotId: req.params.slotId,
+ slotId: (req.params.slotId as string),
+ managementContext: connectionRequestContext(req),
actor: {
agentId: actor.agentId,
runId: req.actor.type === "agent" ? req.actor.runId : null,
@@ -701,7 +709,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
- router.post("/tool-gateway/runtime-slots/:slotId/restart", async (req, res) => {
+ router.post("/tool-gateway/runtime-slots/:slotId/restart", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/restart"), async (req, res) => {
try {
const companyId =
typeof req.body?.companyId === "string"
@@ -717,7 +725,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const actor = getActorInfo(req);
res.json(await toolGateway.restartRuntimeSlot({
companyId,
- slotId: req.params.slotId,
+ slotId: (req.params.slotId as string),
+ managementContext: connectionRequestContext(req),
actor: {
agentId: actor.agentId,
runId: req.actor.type === "agent" ? req.actor.runId : null,
@@ -728,9 +737,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
- router.get("/tool-gateway/audit", async (req, res) => {
+ router.get("/tool-gateway/audit", managementGuard("connections:audit", "GET /tool-gateway/audit"), async (req, res) => {
try {
- assertBoard(req);
+ assertBoardOrAgent(req);
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
@@ -862,6 +871,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
row: toolCallEvents,
invocationId: toolInvocations.id,
invocationAgentId: toolInvocations.agentId,
+ invocationIssueId: toolInvocations.issueId,
invocationApplicationId: toolInvocations.applicationId,
invocationConnectionId: toolInvocations.connectionId,
invocationToolName: toolInvocations.toolName,
@@ -921,7 +931,12 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
return byTime !== 0 ? byTime : b.id.localeCompare(a.id);
});
const hasMore = candidates.length > limit;
- const visible = candidates.slice(0, limit);
+ const window = candidates.slice(0, limit);
+ const visible = await filterConnectionManagementRows(db, req, "connections:audit", window, candidate => candidate.kind === "call"
+ ? { connectionId: candidate.item.row.connectionId ?? candidate.item.invocationConnectionId,
+ issueId: candidate.item.invocationIssueId ?? candidate.item.row.issueId,
+ gatewayId: candidate.item.row.gatewayId }
+ : { connectionId: candidate.item.connectionId });
const agentIds = [...new Set(visible.flatMap((item) => [
item.kind === "call" ? item.item.row.agentId : item.item.agentId,
item.kind === "call" ? item.item.invocationAgentId : null,
@@ -1068,7 +1083,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
};
});
- const last = visible.at(-1);
+ const last = window.at(-1);
res.json({
events,
nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null,
diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts
index 72b18795541..5b10dac43b5 100644
--- a/server/src/services/activity-log.ts
+++ b/server/src/services/activity-log.ts
@@ -68,6 +68,7 @@ export interface LogActivityInput {
issueId?: string | null;
details?: Record | null;
responsibleUserIdOverride?: string | null;
+ authorization?: { action: string; operation: string; reason: string };
}
@@ -163,7 +164,7 @@ export function publishActivity(publication: ActivityPublication) {
}
export async function persistActivity(db: Db, input: LogActivityInput) {
- const redactedDetails = await redactActivityDetails(db, input.details ?? null);
+ const redactedDetails = await redactActivityDetails(db, input.authorization ? { ...input.details, permissionDecision: input.authorization, ...(input.agentApiKeyId ? { agentApiKeyId: input.agentApiKeyId } : {}) } : input.details ?? null);
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
const insert = (runner: Db) => runner.insert(activityLog).values({
companyId: input.companyId,
diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts
index 5bea58456d2..61bfdac9c2e 100644
--- a/server/src/services/ai-connection-router.ts
+++ b/server/src/services/ai-connection-router.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { and, eq, ne, sql } from "drizzle-orm";
import { aiConnectionPools, aiConnectionRouterCursors, aiConnectionTaskPins, companySecrets, connectionGrants, instanceSettings, plugins, toolApplications, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db";
@@ -117,7 +118,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
}
return result;
}
- async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string) {
+ async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string, managementContext?: ConnectionManagementContext) {
await enabled();
const plugin = await owner(pluginKey);
const config = aiConnectionPoolConfigSchema.parse(input.config);
@@ -127,6 +128,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
poolMemberRuntimeConfig(member, "paperclip_runner");
}
const id = input.id ?? randomUUID();
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
if (input.id) {
const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, id), eq(aiConnectionPools.companyId, input.companyId), eq(aiConnectionPools.pluginKey, pluginKey))).for("update");
@@ -146,13 +148,14 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
await tx.insert(aiConnectionRouterCursors).values({ poolId: id, companyId: input.companyId });
await tx.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: id, targetType: "company", targetId: input.companyId, createdByUserId: userId });
}
- await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } });
+ await logActivity(tx as unknown as Db, { companyId: input.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } });
});
return (await list(input.companyId)).find((p) => p.id === id)!;
}
- async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string) {
+ async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string, managementContext?: ConnectionManagementContext) {
// Archive the virtual connection; cascading deletion would erase task pins.
// Cleanup remains available when the experimental flag or plugin is off.
+ await managementContext?.reauthorize();
await db.transaction(async tx => {
const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, poolId), eq(aiConnectionPools.companyId, companyId))).for("update");
if (!row) throw notFound("Connection pool not found");
@@ -161,7 +164,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
if (row.revision !== expectedRevision) throw conflict("Pool changed; reload before deleting");
await tx.update(aiConnectionPools).set({ config: { ...row.config, enabled: false }, revision: row.revision + 1, updatedAt: new Date() }).where(eq(aiConnectionPools.id, poolId));
await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(and(eq(toolConnections.id, poolId), eq(toolConnections.companyId, companyId)));
- await logActivity(tx as unknown as Db, { companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } });
+ await logActivity(tx as unknown as Db, { companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } });
});
return { ok: true };
}
diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts
index 434887f4dc2..ea88cd9cca1 100644
--- a/server/src/services/ai-connections.ts
+++ b/server/src/services/ai-connections.ts
@@ -1,4 +1,5 @@
import { notifyLoginSessionChanged } from "./login-cleanup-notifications.js";
+import type { ConnectionManagementContext } from "./connection-management.js";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
@@ -572,7 +573,9 @@ export function aiConnectionService(db: Db) {
verifiedCredential: string,
loginSession?: { id: string; complete?: (tx: Db) => Promise },
attemptStartedAt = new Date(),
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const routing = "routing" in input ? input.routing : undefined;
if (!(await membership(companyId, userId)))
throw forbidden("An active company member must own this connection");
@@ -610,6 +613,7 @@ export function aiConnectionService(db: Db) {
const id = reconnect?.connection.id ?? randomUUID();
const grantId = reconnect?.grant.id ?? randomUUID();
const persist = () => withAccountHomeSecretMutationLock(undefined, companyId, () => db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
const secrets = secretService(tx);
if (loginSession) {
const [session] = await tx
@@ -902,8 +906,7 @@ export function aiConnectionService(db: Db) {
await loginSession?.complete?.(tx as unknown as Db);
await logActivity(tx as unknown as Db, {
companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: reconnect
? "ai_connection.reconnected"
: "ai_connection.connected",
diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts
index 71d4affa174..0deca602bc7 100644
--- a/server/src/services/authorization.ts
+++ b/server/src/services/authorization.ts
@@ -54,6 +54,10 @@ function measureWorkspaceAuthorization(phase: Extract>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`,
@@ -1105,11 +1125,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
- const issueId = typeof context?.issueId === "string"
+ const issueId = row.issueId ?? (typeof context?.issueId === "string"
? context.issueId.trim()
: typeof context?.taskId === "string"
? context.taskId.trim()
- : "";
+ : "");
return issueId || null;
}
@@ -1151,9 +1171,9 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
}
async function loadResourceContext(resource: AuthorizationResource) {
- const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null;
+ const issue = (resource.type === "issue" || resource.type === "connection_management") && resource.issueId ? await loadIssue(resource.issueId) : null;
const projectId =
- resource.type === "issue"
+ resource.type === "issue" || resource.type === "connection_management"
? issue?.projectId ?? resource.projectId ?? null
: resource.type === "project"
? resource.projectId ?? null
@@ -1168,7 +1188,12 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
companyId: string;
resource: AuthorizationResource;
}): Promise {
- const { issue, project } = await loadResourceContext(input.resource);
+ const runIssueId = input.resource.type === "connection_management"
+ ? await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgent.id)
+ : null;
+ const { issue, project } = await loadResourceContext(runIssueId
+ ? { type: "issue", companyId: input.companyId, issueId: runIssueId }
+ : input.resource);
const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id);
const resolution = resolveCoreTrustPreset({
companyId: input.companyId,
@@ -1302,6 +1327,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
}
if (
+ isConnectionManagementAction(input.action) ||
input.action === "company_scope:read" ||
// Agent creation is a company-wide privileged action. The default-on
// canCreateAgents flag must never reach the legacy creator allow when
@@ -2054,6 +2080,40 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
explanation: "Board user id is required.",
});
}
+ if (isConnectionManagementAction(input.action) && input.resource.type === "connection_management") {
+ const membership = await getActiveMembership(companyId, "user", input.actor.userId);
+ if (!membership || (!input.resource.readOnly && input.action !== "connections:read" && membership.membershipRole === "viewer")) {
+ return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Connection management requires an active, eligible company membership." });
+ }
+ if (input.resource.readOnly || input.action === "connections:read") {
+ return allow({ action: input.action, reason: "allow_company_member", explanation: "Management metadata is visible to active company members, subject to resource visibility." });
+ }
+ if (input.resource.selfServiceUserId === input.actor.userId &&
+ ["connections:read", "connections:configure", "connections:profiles"].includes(input.action)) {
+ return allow({ action: input.action, reason: "allow_self", explanation: "Allowed by personal account or connection creator authority." });
+ }
+ if (input.action === "connections:profiles" && input.resource.selfServiceAgentInstalls) {
+ return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
+ action: input.action, permissionKey: "agents:configure", scope: input.scope });
+ }
+ const userPermission = input.action === "connections:gateway" ? "tools:admin"
+ : input.action === "connections:profiles" && input.resource.operation.includes("/delegations") ? "tools:manage_connections"
+ : permissionKey!;
+ const granted = await decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
+ action: input.action, permissionKey: userPermission, scope: input.scope });
+ // Existing human setup tests are available to connection managers. The
+ // acting agent still needs tools:use and normal execution policy.
+ if (!granted.allowed && granted.reason === "deny_missing_grant" && input.action === "connections:test") {
+ return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
+ action: input.action, permissionKey: "tools:manage_connections", scope: input.scope });
+ }
+ if (!granted.allowed && granted.reason === "deny_missing_grant" && input.action === "connections:profiles" &&
+ ["owner", "admin"].includes(membership.membershipRole ?? "")) {
+ return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
+ action: input.action, permissionKey: "tools:admin", scope: input.scope });
+ }
+ return granted;
+ }
if (taskAssignmentAction) {
if (!(await assignmentTargetIsInCompany(input.resource))) {
return deny({
@@ -2216,6 +2276,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
});
}
+ if (isConnectionManagementAction(input.action) &&
+ (input.actor.keyScope?.kind === "skill_test" || input.actor.keyScope?.kind === "task_bridge")) {
+ return deny({ action: input.action, reason: "deny_scope", explanation: "Scoped test and task bridge credentials cannot administer connections." });
+ }
+
if (input.actor.keyScope?.kind === "skill_test") {
const skillTestDecision = decideSkillTestAccess({
action: input.action,
@@ -2664,6 +2729,10 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
agentDecision: AuthorizationDecision,
): Promise {
const responsibleUserId = input.actor.onBehalfOfUserId?.trim();
+ if (input.actor.type === "agent" && isConnectionManagementAction(input.action) && !responsibleUserId) {
+ return deny({ action: input.action, reason: "deny_missing_membership", code: "RESPONSIBLE_USER_UNAVAILABLE",
+ explanation: "Connection management requires an authenticated responsible user." });
+ }
if (
input.actor.type !== "agent" ||
input.action === "inbox:manage" ||
@@ -2674,11 +2743,9 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
}
const companyId = companyIdForResource(input.resource);
- const snapshot = await getResponsibleUserSnapshot({
- actor: input.actor,
- companyId,
- userId: responsibleUserId,
- });
+ const snapshot = isConnectionManagementAction(input.action)
+ ? await loadResponsibleUserSnapshot(companyId, responsibleUserId)
+ : await getResponsibleUserSnapshot({ actor: input.actor, companyId, userId: responsibleUserId });
const denyCode: AuthorizationDecision["code"] =
snapshot.userExists && snapshot.activeMembership
? "RESPONSIBLE_USER_UNAUTHORIZED"
@@ -2750,7 +2817,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
responsibleUserId,
}, "responsible-user authorization intersection denied");
- return input.action !== "agent_instructions:update" && responsibleUserAuthzShadowMode() ? agentDecision : denied;
+ return input.action !== "agent_instructions:update" && !isConnectionManagementAction(input.action) && responsibleUserAuthzShadowMode() ? agentDecision : denied;
}
async function applyAuthorizingUserIntersection(
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index e53608b49de..a9892a79d9b 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -12,6 +12,7 @@ import { applyChatTaskReviewContainment } from "./chat-channels/inbound/task-tru
import { photonConversationBindingIsComplete, photonControlTargetsConversation, photonReplyMetadata, recordPhotonInboundActivity } from "./chat-channels/imessage-photon/messages.js";
import { deferTeamsSetupReply } from "./chat-channels/microsoft-teams/setup-replies.js";
import { slackOrphanFollowUp, stripSlackTitleMentions, slackMessageMeetsMentionRequirement } from "./chat-channels/slack/messages.js";
+import type { ConnectionManagementContext } from "./connection-management.js";
import { createChatPublicationResolution } from "./chat-channels/publications/resolution.js";
import { createChatPublicationRequests } from "./chat-channels/publications/requests.js";
import { createTeamsBoardSendPreparation } from "./chat-channels/microsoft-teams/board-send-preparation.js";
@@ -3479,7 +3480,7 @@ return {
configure,
inspectPhoton,
test,
- finishSlackSetup: (endpointId: string, userId: string) => test(endpointId, { optionalSlackTestForUser: userId }),
+ finishSlackSetup: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => test(endpointId, { optionalSlackTestForUser: userId }, managementContext),
setupTestStatus,
handleWebhook,
listResources,
diff --git a/server/src/services/chat-channels/activity.ts b/server/src/services/chat-channels/activity.ts
index 2faed157013..0637780aef7 100644
--- a/server/src/services/chat-channels/activity.ts
+++ b/server/src/services/chat-channels/activity.ts
@@ -1,3 +1,4 @@
+import { connectionManagementService, type ConnectionManagementContext } from "../connection-management.js";
import { providerEffectPayload } from "./provider-effects.js";
import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js";
@@ -161,7 +162,7 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) {
});
}
- async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) {
+ async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }, managementContext?: ConnectionManagementContext) {
const limit = page ? page.limit + 1 : 100;
// Match the millisecond precision of the public timestamp and use a UUID
// tie-breaker so equal timestamps never skip records between pages.
@@ -332,6 +333,23 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) {
)
cancellableConflicts.set(publication.id, transfer.version);
}
+ // An endpoint manager may still lack access to individual private tasks.
+ let visibleIds: Set | undefined;
+ if (managementContext?.actor.type === "agent") {
+ const conversations = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpointId));
+ const visibleConversations = new Set();
+ for (const conversation of conversations) {
+ const decision = await connectionManagementService(db).capability(managementContext.actor, "connections:audit",
+ { ...managementContext.resource, issueId: conversation.issueId }, { mutation: false });
+ if (decision.allowed) visibleConversations.add(conversation.id);
+ }
+ const visible = (row: { id: string; conversationId: string | null }) => !row.conversationId || visibleConversations.has(row.conversationId);
+ visibleIds = new Set([
+ ...deliveries, ...publications, ...slashActions, ...providerEffects,
+ ...githubIngressFailures, ...slackSessionActions, ...githubRecoveries.map(row => row.action),
+ ].filter(visible).map(row => row.id));
+ githubRecoveryScans.forEach(row => visibleIds!.add(row.id));
+ }
return [
...deliveries.map((row) => {
const normalized =
@@ -550,11 +568,12 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) {
})),
...githubRecoveryScans,
]
+ .filter(row => !visibleIds || visibleIds.has(row.id))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
.slice(0, limit);
}
- async function listActivityPage(endpointId: string, limit = 25, cursor?: string) {
+ async function listActivityPage(endpointId: string, limit = 25, cursor?: string, managementContext?: ConnectionManagementContext) {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100");
let before: { createdAt: string; id: string } | undefined;
if (cursor !== undefined) {
@@ -567,7 +586,7 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) {
before = { createdAt: value[0], id: value[1] };
} catch { throw badRequest("Invalid activity cursor"); }
}
- const rows = await listActivity(endpointId, { limit, before });
+ const rows = await listActivity(endpointId, { limit, before }, managementContext);
const items = rows.slice(0, limit);
const last = items.at(-1);
return {
diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts
index 80fbaafd756..eda63d37f31 100644
--- a/server/src/services/chat-channels/configuration.ts
+++ b/server/src/services/chat-channels/configuration.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "../connection-management.js";
import { preparePhotonConfiguration, createPhotonConfiguration } from "./imessage-photon/setup.js";
import { slackRuntimeConfig } from "./slack/setup.js";
import { createTelegramSetup } from "./telegram/setup.js";
@@ -113,7 +114,9 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
endpointId: string,
input: ConfigureChatEndpointInput,
actorUserId?: string | null,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
if (record.endpoint.provider === "agentmail") throw badRequest("Use the email inbox API for AgentMail");
@@ -148,6 +151,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
input,
credentialLease,
actorUserId,
+ managementContext,
),
);
}
@@ -157,7 +161,9 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
input: ConfigureChatEndpointInput,
credentialLease: CredentialMutationLeaseGuard,
actorUserId?: string | null,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
const endpoint = record.endpoint;
@@ -167,6 +173,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
code: "chat_endpoint_not_active",
});
}
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
const pausedAt = new Date();
@@ -219,8 +226,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
await invalidateRuntime(endpoint.id);
await logActivity(db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.paused",
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -280,7 +286,8 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
);
let activatedEndpoint!: EndpointRow;
try {
- await db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
+ await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
const resumedAt = new Date();
const current = await tx
@@ -362,7 +369,8 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
if (activatedEndpoint) {
const failure = redactError(error);
const failedAt = new Date();
- await db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
+ await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
await notifyChatEndpointWork(tx, endpoint.connectionId);
await tx
@@ -415,8 +423,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
}
await logActivity(db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.resumed",
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -454,6 +461,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
code: "chat_endpoint_already_removed",
});
}
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
const current = await tx
@@ -696,6 +704,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
// it never leaves credentials bound to the losing endpoint.
if (!endpoint.botExternalId) {
try {
+ await managementContext?.reauthorize();
const claimed = await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
await notifyChatEndpointWork(tx, endpoint.connectionId);
@@ -762,6 +771,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
endpoint.provider === "slack" &&
(input.action === "configure" || input.action === "reconnect");
try {
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
const current = await tx
@@ -861,6 +871,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
} catch (error) {
const failure = redactError(error);
await invalidateRuntime(endpoint.id).catch(() => undefined);
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
await notifyChatEndpointWork(tx, endpoint.connectionId);
@@ -888,12 +899,12 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption
throw unprocessable(failure, { code: "chat_provider_setup_failed" });
}
- await db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
+ await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
await logActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action:
input.action === "reconnect"
? "chat_endpoint.reconnected"
diff --git a/server/src/services/chat-channels/endpoints.ts b/server/src/services/chat-channels/endpoints.ts
index 842860e1036..b391f44646d 100644
--- a/server/src/services/chat-channels/endpoints.ts
+++ b/server/src/services/chat-channels/endpoints.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "../connection-management.js";
import { slackSetupState } from "./slack/setup.js";
import { assertChatExecutionDefaultsAccess } from "../chat-execution-defaults.js";
@@ -240,7 +241,9 @@ function providerSetupState(
companyId: string,
input: CreateChatEndpointInput,
actorUserId?: string | null,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail");
if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections");
if (input.slackSetupMethod === "managed" && options.slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable");
@@ -273,7 +276,8 @@ function providerSetupState(
const name = (
input.name?.trim() || `${PROVIDER_LABELS[input.provider]} — ${agent.name}`
).slice(0, 145);
- await db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
+ await db.transaction(async (tx) => {
if (input.applicationId) {
const application = await tx
.select({
@@ -362,8 +366,7 @@ function providerSetupState(
});
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.created",
entityType: "tool_connection",
entityId: connectionId,
@@ -380,7 +383,9 @@ function providerSetupState(
endpointId: string,
input: UpdateChatEndpointInput,
actorUserId?: string | null,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
if (initial.endpoint.provider === "agentmail" && Object.keys(input).some((key) => key !== "executionDefaults")) throw badRequest("Use the email inbox API for AgentMail");
@@ -442,13 +447,14 @@ function providerSetupState(
values.allowGroupChats = input.allowGroupChats;
if (input.allowUnlinkedPeople !== undefined)
values.allowUnlinkedPeople = input.allowUnlinkedPeople;
- await db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
+ await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
if (input.executionDefaults !== undefined) {
await assertChatExecutionDefaultsAccess(db, {
companyId: existing.endpoint.companyId,
assigneeAgentId: values.assignedAgentId ?? existing.endpoint.assignedAgentId,
- actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
+ actor: managementContext?.actor ?? { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
defaults: input.executionDefaults,
}, tx);
values.executionDefaults = input.executionDefaults;
@@ -459,8 +465,7 @@ function providerSetupState(
.where(eq(chatEndpoints.id, endpointId));
await logActivity(tx as unknown as Db, {
companyId: existing.endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.updated",
entityType: "tool_connection",
entityId: existing.endpoint.connectionId,
diff --git a/server/src/services/chat-channels/github/app-setup.ts b/server/src/services/chat-channels/github/app-setup.ts
index 55466475cda..96a65975580 100644
--- a/server/src/services/chat-channels/github/app-setup.ts
+++ b/server/src/services/chat-channels/github/app-setup.ts
@@ -1,4 +1,5 @@
import type { ChatEndpointSetupState } from "@paperclipai/shared";
+import type { ConnectionManagementContext } from "../../connection-management.js";
import type { VerifiedProviderIdentity } from "../runtime-types.js";
import { createSetupProbes } from "../setup-probes.js";
@@ -77,8 +78,8 @@ type GitHubAppSetupOptions = {
reconcileProviderResourceRows: ReturnType["reconcileProviderResourceRows"];
listResources: ReturnType["listResources"];
replaceResources: ReturnType["replaceResources"];
- update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null) => Promise;
- configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null) => Promise;
+ update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise;
+ configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise;
test: ReturnType["test"];
githubWizardOrigin?: string | null;
handleWebhook: (publicId: string, provider: "github", request: Request) => Promise;
@@ -88,7 +89,8 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
const { endpointRecord, withCredentialMutationLease, normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, resolveCredentials, fetchImpl, get, getPublicBaseUrl, getWebhookPublicBaseUrl, prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook } = options;
- async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app") {
+ async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app", managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found");
await withCredentialMutationLease(initial.endpoint, async credentialLease => {
@@ -98,6 +100,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
const identity = await verifyCredentials("github", normalized);
if (record.endpoint.botExternalId && !nativeBotIdentityMatches("github", record.endpoint, identity)) throw conflict("This App belongs to a different bot. Create a new connection instead.");
await assertNativeBotIdentityAvailable(record.endpoint, identity);
+ await managementContext?.reauthorize();
// Claim the globally unique App identity before writing credentials.
// A losing concurrent registration must never persist usable secrets.
if (!record.endpoint.botExternalId) {
@@ -119,6 +122,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
throw error;
}
}
+ await managementContext?.reauthorize();
await persistCredentials(record.endpoint, normalized, credentialLease, userId);
const slug = identity.botUsername?.replace(/\[bot\]$/, "");
if (!slug || !/^[a-z0-9-]+$/.test(slug)) throw unprocessable("GitHub did not identify this App");
@@ -146,7 +150,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
sql`${chatGitHubRegistrations.handoff} ? 'manifestClaimId'`,
));
}
- await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, actorType: "user", actorId: userId, action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } });
+ await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } });
});
});
// Manifest registration can send its ping before the credential exchange
@@ -167,10 +171,11 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
const githubRegistration = githubChatRegistrationService(db, {
publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl,
- storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest"); },
+ storeApp: async (endpointId, userId, app, managementContext) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest", managementContext); },
});
- async function refreshGitHubRepositories(endpointId: string, userId: string) {
+ async function refreshGitHubRepositories(endpointId: string, userId: string, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found");
await withCredentialMutationLease(initial.endpoint, async lease => {
@@ -178,6 +183,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
if (!record) throw notFound("GitHub bot not found");
const credentials = await resolveCredentials(record.endpoint);
const prepared = await prepareProviderInventory(record.endpoint, credentials);
+ await managementContext?.reauthorize();
if (prepared.credentials.installationId !== credentials.installationId) await persistCredentials(record.endpoint, prepared.credentials, lease, userId);
if (prepared.inventory) await reconcileProviderResourceRows(record.endpoint, prepared.inventory, lease);
await lease.assertOwned();
@@ -188,7 +194,7 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
const githubWizard = githubChatWizardService(db, {
origin: () => getPublicBaseUrl() ?? options.githubWizardOrigin ?? null, fetch: fetchImpl, startDirect: githubRegistration.start, completeDirect: githubRegistration.complete, resumeDirect: githubRegistration.resumeRegistration,
- storeApp: (id, userId, credentials) => storeGitHubApp(id, userId, credentials, "manifest"),
+ storeApp: (id, userId, credentials, managementContext) => storeGitHubApp(id, userId, credentials, "manifest", managementContext),
storeCredentials: async (id, userId, additional) => {
const record = await endpointRecord(id);
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
@@ -200,11 +206,11 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
});
},
refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources,
- configure: async (id, userId) => {
- await update(id, { allowGroupChats: true }, userId);
- return configure(id, { action: "configure" }, userId);
+ configure: async (id, userId, context) => {
+ await update(id, { allowGroupChats: true }, userId, context);
+ return configure(id, { action: "configure" }, userId, context);
},
- finish: (id, userId) => test(id, { optionalSlackTestForUser: userId }),
+ finish: (id, userId, context) => test(id, { optionalSlackTestForUser: userId }, context),
});
const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress(
db,
diff --git a/server/src/services/chat-channels/github/setup-secret.ts b/server/src/services/chat-channels/github/setup-secret.ts
index 4fb2cb43fe3..4d96d51f99b 100644
--- a/server/src/services/chat-channels/github/setup-secret.ts
+++ b/server/src/services/chat-channels/github/setup-secret.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "../../connection-management.js";
import type { EndpointRow, InternalSetupState } from "../runtime-types.js";
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
@@ -57,7 +58,9 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
async function generateSetupSecret(
endpointId: string,
actorUserId?: string | null,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
if (initial.endpoint.provider !== "github") {
@@ -123,23 +126,25 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
// state moves fail-closed before ref replacement. Once refs change, only
// best-effort work remains and the plaintext must still be returned.
await db.transaction(async (tx) => {
+ await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
await writeSetupSecretActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.setup_secret_rotation_started",
entityType: "tool_connection",
entityId: endpoint.connectionId,
details: auditDetails,
});
+ await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
});
try {
if (rotatingConfiguredApp) {
const updatedAt = new Date();
await db.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
+ await managementContext?.reauthorize();
+ await credentialLease.assertOwned(tx);
const current = await tx
.select({ setup: chatEndpoints.setup })
.from(chatEndpoints)
@@ -176,7 +181,8 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
updatedAt,
})
.where(eq(toolConnections.id, endpoint.connectionId));
- await credentialLease.assertOwned(tx);
+ await managementContext?.reauthorize();
+ await credentialLease.assertOwned(tx);
});
await invalidateRuntime(endpoint.id).catch(() => undefined);
} else {
@@ -186,7 +192,8 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
// endpoints affected by the earlier pre-connect rotation trap.
const updatedAt = new Date();
await db.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
+ await managementContext?.reauthorize();
+ await credentialLease.assertOwned(tx);
const current = await tx
.select({ setup: chatEndpoints.setup })
.from(chatEndpoints)
@@ -220,7 +227,8 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
updatedAt,
})
.where(eq(toolConnections.id, endpoint.connectionId));
- await credentialLease.assertOwned(tx);
+ await managementContext?.reauthorize();
+ await credentialLease.assertOwned(tx);
});
}
await options.setupSecretCredentialPersistBarrier?.();
@@ -245,8 +253,7 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
} catch (error) {
await writeSetupSecretActivity(db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.setup_secret_rotation_failed",
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -265,17 +272,18 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption
}
await db
.transaction(async (tx) => {
- await credentialLease.assertOwned(tx);
+ await managementContext?.reauthorize();
+ await credentialLease.assertOwned(tx);
await writeSetupSecretActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.setup_secret_generated",
entityType: "tool_connection",
entityId: endpoint.connectionId,
details: auditDetails,
});
- await credentialLease.assertOwned(tx);
+ await managementContext?.reauthorize();
+ await credentialLease.assertOwned(tx);
})
.catch((error) => {
logger.warn(
diff --git a/server/src/services/chat-channels/resource-reconciliation.ts b/server/src/services/chat-channels/resource-reconciliation.ts
index 66a82e76ff3..0869aff07e4 100644
--- a/server/src/services/chat-channels/resource-reconciliation.ts
+++ b/server/src/services/chat-channels/resource-reconciliation.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "../connection-management.js";
import { imessageResourceParticipants } from "./imessage-photon/resources.js";
import { createGitHubResources, reconcileGitHubRepositoryCoordinates } from "./github/resources.js";
import { baseTeamsConversationId } from "./microsoft-teams/resources.js";
@@ -166,7 +167,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
endpointId: string,
updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>,
actorUserId?: string | null,
- options?: { initialGitHubImport?: boolean },
+ options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext },
) {
await updateResourceSelection(endpointId, updates, actorUserId, options);
return listResources(endpointId);
@@ -176,8 +177,9 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
endpointId: string,
updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>,
actorUserId?: string | null,
- options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean },
+ options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean; managementContext?: ConnectionManagementContext },
) {
+ await options?.managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
const bulk = options?.allGitHubRepositoriesEnabled !== undefined;
@@ -263,7 +265,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
await assertChatExecutionDefaultsAccess(db, {
companyId: endpoint.companyId,
assigneeAgentId: endpoint.assignedAgentId,
- actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
+ actor: options?.managementContext?.actor ?? { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
defaults: entry.executionDefaults,
}, tx);
finalDefaults.set(entry.id, entry.executionDefaults);
@@ -306,8 +308,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
tx as unknown as Db,
{
companyId: endpoint.companyId,
- actorType: "user",
- actorId: actorUserId ?? "board",
+ ...(options?.managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.resources_updated",
entityType: "tool_connection",
entityId: endpoint.connectionId,
diff --git a/server/src/services/chat-channels/setup-probes.ts b/server/src/services/chat-channels/setup-probes.ts
index 3aeb21f8833..74aec70c43e 100644
--- a/server/src/services/chat-channels/setup-probes.ts
+++ b/server/src/services/chat-channels/setup-probes.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "../connection-management.js";
import type { EndpointRow, DbOrTransaction } from "./runtime-types.js";
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
@@ -94,7 +95,8 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) {
return { messageReceivedAt: at?.toISOString() ?? null };
}
- async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) {
+ async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
return withCredentialMutationLease(
@@ -287,6 +289,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) {
}
}
await options.setupTestActivationBarrier?.();
+ await managementContext?.reauthorize();
const expectedGeneration = runtimeGeneration(endpoint.setup);
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
@@ -339,7 +342,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) {
})
.where(eq(toolConnections.id, endpoint.connectionId));
if (optionalSlackTest) await logActivity(tx as unknown as Db, {
- companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser,
+ companyId: endpoint.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: finishOptions!.optionalSlackTestForUser }),
action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId,
details: { conversationTestOptional: true },
});
diff --git a/server/src/services/chat-github-management.ts b/server/src/services/chat-github-management.ts
index c4ba12717d0..c4bf27e1330 100644
--- a/server/src/services/chat-github-management.ts
+++ b/server/src/services/chat-github-management.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "./connection-management.js";
import { githubBotCredentials } from "./chat-github-client.js";
import { toolAccessPolicyService } from "./tool-access-policy.js";
import { agents, toolCatalogEntries } from "@paperclipai/db";
@@ -90,7 +91,9 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) {
endpointId: string,
input: unknown,
userId: string,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const bot = await endpoint(endpointId);
await member(bot.companyId, userId);
const parsed = updateGitHubChatConfigurationSchema.parse(input);
@@ -185,8 +188,7 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) {
await syncGitHubBotTools(tx, bot, userId, config.toolsEnabled);
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.configuration_updated",
entityType: "tool_connection",
entityId: bot.connectionId,
diff --git a/server/src/services/chat-github-registration.ts b/server/src/services/chat-github-registration.ts
index ae5019e17b4..43f7c2f864e 100644
--- a/server/src/services/chat-github-registration.ts
+++ b/server/src/services/chat-github-registration.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "./connection-management.js";
import { createHash, randomBytes } from "node:crypto";
import { and, eq, gt, sql } from "drizzle-orm";
import {
@@ -52,6 +53,7 @@ export function githubChatRegistrationService(
clientId?: string;
clientSecret?: string;
},
+ managementContext?: ConnectionManagementContext,
) => Promise;
},
) {
@@ -59,7 +61,9 @@ export function githubChatRegistrationService(
endpointId: string,
userId: string,
input: string | GitHubAppRegistrationInput,
+ managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const {
name,
ownerType = "personal",
@@ -111,6 +115,7 @@ export function githubChatRegistrationService(
.where(eq(companies.id, endpoint.companyId));
const state = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 30 * 60_000);
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
const [lockedEndpoint] = await tx
.select()
@@ -151,6 +156,7 @@ export function githubChatRegistrationService(
appName: name.trim(),
handoff: {
cloudId: "",
+ ...(managementContext?.actor.type === "board" && managementContext.actor.sessionId ? { browserSessionId: managementContext.actor.sessionId } : {}),
redemptionId: randomBytes(32).toString("base64url"),
returnState: state,
},
@@ -158,8 +164,7 @@ export function githubChatRegistrationService(
});
await logActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.registration_started",
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -234,7 +239,8 @@ export function githubChatRegistrationService(
},
};
}
- async function resumeRegistration(endpointId: string, userId: string) {
+ async function resumeRegistration(endpointId: string, userId: string, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const [session] = await db
.select()
.from(chatGitHubRegistrations)
@@ -277,7 +283,8 @@ export function githubChatRegistrationService(
expiresAt: session.expiresAt,
});
}
- async function complete(state: string, code: string) {
+ async function complete(state: string, code: string, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
if (
!/^[A-Za-z0-9_-]{43}$/.test(state) ||
!/^[a-zA-Z0-9_-]{1,256}$/.test(code)
@@ -353,6 +360,7 @@ export function githubChatRegistrationService(
app.owner?.login?.toLowerCase() !== session.ownerLogin?.toLowerCase())
)
throw badRequest("GitHub returned an App owned by a different account");
+ await managementContext?.reauthorize();
await options.storeApp(session.endpointId, session.userId, {
appId: String(app.id),
privateKey: app.pem,
@@ -360,7 +368,7 @@ export function githubChatRegistrationService(
slug: app.slug,
clientId: app.client_id,
clientSecret: app.client_secret,
- });
+ }, managementContext);
await db
.update(chatGitHubRegistrations)
.set({ status: "completed" })
diff --git a/server/src/services/chat-github-wizard.ts b/server/src/services/chat-github-wizard.ts
index 701d77cba51..abe1e3a6754 100644
--- a/server/src/services/chat-github-wizard.ts
+++ b/server/src/services/chat-github-wizard.ts
@@ -1,3 +1,5 @@
+import type { AuthorizationActor } from "./authorization.js";
+import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js";
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, sql } from "drizzle-orm";
import {
@@ -73,6 +75,7 @@ export function githubChatWizardService(
endpointId: string,
userId: string,
input: GitHubAppRegistrationInput,
+ managementContext?: ConnectionManagementContext,
) => Promise<{
registrationUrl: string;
manifest: Record;
@@ -81,16 +84,18 @@ export function githubChatWizardService(
resumeDirect?: (
endpointId: string,
userId: string,
+ managementContext?: ConnectionManagementContext,
) => Promise<{
registrationUrl: string;
manifest: Record;
expiresAt: string;
}>;
- completeDirect?: (state: string, code: string) => Promise;
+ completeDirect?: (state: string, code: string, managementContext?: ConnectionManagementContext) => Promise;
storeApp: (
endpointId: string,
userId: string,
credentials: Record,
+ managementContext?: ConnectionManagementContext,
) => Promise;
storeCredentials: (
endpointId: string,
@@ -100,16 +105,17 @@ export function githubChatWizardService(
refreshRepositories: (
endpointId: string,
userId: string,
+ managementContext?: ConnectionManagementContext,
) => Promise;
resources: (endpointId: string) => Promise;
replaceResources: (
endpointId: string,
updates: Array<{ id: string; enabled: boolean }>,
userId: string,
- options?: { initialGitHubImport?: boolean },
+ options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext },
) => Promise;
- configure: (endpointId: string, userId: string) => Promise;
- finish: (endpointId: string, userId: string) => Promise;
+ configure: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise;
+ finish: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise;
},
) {
const fetchImpl = options.fetch ?? fetch;
@@ -214,8 +220,9 @@ export function githubChatWizardService(
async function start(
id: string,
userId: string,
- input: GitHubAppRegistrationInput,
+ input: GitHubAppRegistrationInput, managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const bot = await endpoint(id, userId);
if (bot.botExternalId)
throw conflict(
@@ -229,13 +236,13 @@ export function githubChatWizardService(
throw conflict(
"Recover the existing App from GitHub settings; its registration cannot safely be repeated.",
);
- if (prior.handoff) return advance(id, userId);
+ if (prior.handoff) return advance(id, userId, managementContext);
// The direct flow's hashed state cannot be reconstructed. Do not create a duplicate App.
throw conflict(
"An App registration is already pending. Complete its open GitHub page, or use existing-App recovery.",
);
}
- await saveDraft(id, userId, input);
+ await saveDraft(id, userId, input, managementContext);
// Only a fresh registration may import GitHub's initial selection. Old
// drafts without this marker retain even an entirely disabled selection.
if (
@@ -249,7 +256,7 @@ export function githubChatWizardService(
return {
endpointId: id,
state: "create",
- registration: await options.startDirect(id, userId, input),
+ registration: await options.startDirect(id, userId, input, managementContext),
} satisfies GitHubAppWizardState;
return {
endpointId: id,
@@ -302,13 +309,12 @@ export function githubChatWizardService(
ownerLogin: input.ownerLogin,
appName: input.name,
expiresAt: new Date(Date.now() + 30 * 60_000),
- handoff: { cloudId: nonce(), returnState, redemptionId: nonce() },
+ handoff: { cloudId: nonce(), returnState, redemptionId: nonce(), ...(managementContext?.actor.type === "board" && managementContext.actor.sessionId ? { browserSessionId: managementContext.actor.sessionId } : {}) },
})
.returning();
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.registration_started",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -403,6 +409,7 @@ export function githubChatWizardService(
async function exchangeCloudManifest(
session: typeof chatGitHubRegistrations.$inferSelect,
claimId: string,
+ managementContext?: ConnectionManagementContext,
) {
const cloud = connector();
if (!cloud || !session.handoff)
@@ -471,6 +478,7 @@ export function githubChatWizardService(
app.owner.login?.toLowerCase() !== session.ownerLogin.toLowerCase())
)
throw forbidden("GitHub returned an App owned by a different account");
+ await managementContext?.reauthorize();
await options.storeApp(session.endpointId, session.userId, {
appId: String(app.id),
privateKey: app.pem,
@@ -478,7 +486,7 @@ export function githubChatWizardService(
clientId: app.client_id,
clientSecret: app.client_secret,
slug: app.slug,
- });
+ }, managementContext);
await db
.update(chatGitHubRegistrations)
.set({
@@ -500,7 +508,8 @@ export function githubChatWizardService(
session.expiresAt <= new Date() && !session.consumedAt &&
!session.handoff.manifestClaimId;
}
- async function restartRegistration(id: string, userId: string, registrationId: string) {
+ async function restartRegistration(id: string, userId: string, registrationId: string, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const bot = await endpoint(id, userId);
const cloud = connector();
if (!cloud) throw conflict("Restore Paperclip Cloud enrollment before continuing");
@@ -532,20 +541,21 @@ export function githubChatWizardService(
handoff: { cloudId: nonce(), returnState, redemptionId: nonce(), renewalOf: prior.id },
}).returning();
await logActivity(tx as unknown as Db, {
- companyId: bot.companyId, actorType: "user", actorId: userId,
+ companyId: bot.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.registration_restarted", entityType: "tool_connection", entityId: bot.connectionId,
details: { endpointId: id, registrationId: next!.id, previousRegistrationId: prior.id, appNotCreated: true },
});
return next!;
});
await setup(bot, { cloudRegistrationId: created.handoff!.cloudId, registrationStatus: "pending", stage: "connect" });
- return advance(id, userId);
+ return advance(id, userId, managementContext);
}
async function saveDraft(
id: string,
userId: string,
- input: GitHubAppRegistrationInput,
+ input: GitHubAppRegistrationInput, managementContext?: ConnectionManagementContext,
) {
+ await managementContext?.reauthorize();
const bot = await endpoint(id, userId);
await db.transaction(async (tx) => {
await tx
@@ -571,8 +581,7 @@ export function githubChatWizardService(
.where(eq(chatEndpoints.id, id));
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.draft_saved",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -581,7 +590,8 @@ export function githubChatWizardService(
});
return { saved: true as const };
}
- async function repairCloud(id: string, userId: string) {
+ async function repairCloud(id: string, userId: string, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const bot = await endpoint(id, userId),
session = await registration(bot),
cloud = connector();
@@ -608,8 +618,7 @@ export function githubChatWizardService(
throw conflict("Restore this App's Cloud gateway before continuing");
await logActivity(db, {
companyId: bot.companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.webhook_repair_started",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -659,8 +668,7 @@ export function githubChatWizardService(
}
await logActivity(db, {
companyId: bot.companyId,
- actorType: "user",
- actorId: userId,
+ ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.webhook_repaired",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -669,12 +677,23 @@ export function githubChatWizardService(
}
async function advance(
id: string,
- userId: string,
+ userId: string, managementContext?: ConnectionManagementContext,
): Promise {
+ await managementContext?.reauthorize();
+ // Bind an agent-created handoff when its addressed human opens the saved setup.
+ if (managementContext?.actor.type === "board" && managementContext.actor.sessionId) {
+ const bot = await endpoint(id, userId);
+ const session = await registration(bot);
+ if (session && session.userId === userId && !session.handoff?.browserSessionId) {
+ await db.update(chatGitHubRegistrations).set({
+ handoff: sql`coalesce(${chatGitHubRegistrations.handoff}, '{}'::jsonb) || ${JSON.stringify({ browserSessionId: managementContext.actor.sessionId })}::jsonb`,
+ }).where(and(eq(chatGitHubRegistrations.id, session.id), sql`${chatGitHubRegistrations.handoff}->>'browserSessionId' is null`));
+ }
+ }
// Multiple open tabs for this manager share one operation, not concurrent vault writes.
const key = `${id}:${userId}`;
if (pending.has(key)) return pending.get(key)!;
- const work = advanceOnce(id, userId);
+ const work = advanceOnce(id, userId, managementContext);
pending.set(key, work);
try {
return await work;
@@ -684,8 +703,9 @@ export function githubChatWizardService(
}
async function advanceOnce(
id: string,
- userId: string,
+ userId: string, managementContext?: ConnectionManagementContext,
): Promise {
+ await managementContext?.reauthorize();
let bot = await endpoint(id, userId);
if (["paused", "revoked"].includes(bot.status))
return {
@@ -737,7 +757,7 @@ export function githubChatWizardService(
session.handoff.manifestClaimId &&
!hasAppCredentials
) {
- await exchangeCloudManifest(session, session.handoff.manifestClaimId);
+ await exchangeCloudManifest(session, session.handoff.manifestClaimId, managementContext);
bot = await endpoint(id, userId);
const [savedConnection] = await db
.select({ refs: toolConnections.credentialSecretRefs })
@@ -790,7 +810,7 @@ export function githubChatWizardService(
session.handoff.webhookSecretHash !==
hash(stored.webhookSecret ?? ""))
) {
- await repairCloud(id, userId);
+ await repairCloud(id, userId, managementContext);
bot = await endpoint(id, userId);
}
if (session.status !== "completed")
@@ -826,7 +846,7 @@ export function githubChatWizardService(
return {
endpointId: id,
state: "create",
- registration: await options.resumeDirect(id, userId),
+ registration: await options.resumeDirect(id, userId, managementContext),
};
return {
endpointId: id,
@@ -857,7 +877,7 @@ export function githubChatWizardService(
bot.setup.github?.initialSetupPending
) {
try {
- await options.refreshRepositories(id, userId);
+ await options.refreshRepositories(id, userId, managementContext);
} catch (error) {
return {
endpointId: id,
@@ -892,7 +912,7 @@ export function githubChatWizardService(
.filter((resource) => resource.availability === "available")
.map((resource) => ({ id: resource.id, enabled: true })),
userId,
- { initialGitHubImport: true },
+ { initialGitHubImport: true, managementContext },
);
}
if (initializeApp && saved.revision === 0 && bot.status !== "active")
@@ -911,7 +931,9 @@ export function githubChatWizardService(
},
},
userId,
+ managementContext,
);
+ await managementContext?.reauthorize();
await setup(bot, {
initialRepositoriesImported: true,
initialSetupPending: false,
@@ -946,7 +968,7 @@ export function githubChatWizardService(
bot.status !== "active" &&
(bot.status !== "verifying" || bot.setup.step === "provider_setup")
)
- await options.configure(id, userId);
+ await options.configure(id, userId, ...(managementContext ? [managementContext] : []));
bot = await endpoint(id, userId);
let linked = await linkedIdentity(bot, userId);
if (!linked) {
@@ -1003,7 +1025,7 @@ export function githubChatWizardService(
);
const connectionReady = connectionChecks.every((check) => check.ok);
if (connectionReady && bot.status !== "active")
- await options.finish(id, userId);
+ await options.finish(id, userId, ...(managementContext ? [managementContext] : []));
return {
endpointId: id,
state: connectionReady ? "connected" : "verify",
@@ -1102,19 +1124,32 @@ export function githubChatWizardService(
);
}
}
- async function startIdentity(id: string, userId: string) {
+ async function authorizeBrowserReturn(session: typeof chatGitHubRegistrations.$inferSelect, actor?: AuthorizationActor, identity = false) {
+ // Internal provider workflows already own their authenticated subject. HTTP
+ // callbacks always pass their actual browser actor before consuming state.
+ if (!actor) return;
+ const sessionId = identity ? session.handoff?.identitySessionId : session.handoff?.browserSessionId;
+ if (actor.type !== "board" || actor.userId !== session.userId || (sessionId && actor.sessionId !== sessionId)) throw forbidden("Open this saved setup in the configuring user's browser session");
+ const bot = await endpoint(session.endpointId, session.userId);
+ return connectionManagementService(db).authorize(actor, "connections:configure", {
+ type: "connection_management", companyId: bot.companyId, connectionId: bot.connectionId,
+ targetAgentId: bot.assignedAgentId, purpose: "chat", provider: "github", operation: "github.authentication.return",
+ });
+ }
+ async function startIdentity(id: string, userId: string, actor?: AuthorizationActor) {
const bot = await endpoint(id, userId),
session = await registration(bot);
if (!session || session.userId !== userId)
throw forbidden("The configuring member must link their GitHub account");
return withIdentityLease(session.id, (assertOwned) =>
- startIdentityLocked(id, userId, assertOwned),
+ startIdentityLocked(id, userId, assertOwned, actor),
);
}
async function startIdentityLocked(
id: string,
userId: string,
assertOwned: () => Promise,
+ actor?: AuthorizationActor,
) {
const bot = await endpoint(id, userId),
session = await registration(bot);
@@ -1138,7 +1173,7 @@ export function githubChatWizardService(
await db
.update(chatGitHubRegistrations)
.set({
- handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`,
+ handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ ...(actor?.type === "board" && actor.sessionId ? { identitySessionId: actor.sessionId } : {}), identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -1179,6 +1214,7 @@ export function githubChatWizardService(
session: typeof chatGitHubRegistrations.$inferSelect,
code: string,
redirectUri: string,
+ actor?: AuthorizationActor,
) {
return withIdentityLease(session.id, async (assertOwned) => {
const [current] = await db
@@ -1191,7 +1227,7 @@ export function githubChatWizardService(
session.handoff?.identityStateHash
)
return resume(await endpoint(session.endpointId, session.userId));
- return completeIdentityLocked(current, code, redirectUri, assertOwned);
+ return completeIdentityLocked(current, code, redirectUri, assertOwned, actor);
});
}
async function completeIdentityLocked(
@@ -1199,6 +1235,7 @@ export function githubChatWizardService(
code: string,
redirectUri: string,
assertOwned: () => Promise,
+ actor?: AuthorizationActor,
) {
const bot = await endpoint(session.endpointId, session.userId);
if (
@@ -1283,6 +1320,7 @@ export function githubChatWizardService(
!/^[A-Za-z0-9-]{1,39}$/.test(user.login)
)
throw conflict("GitHub returned an invalid account");
+ await authorizeBrowserReturn(session, actor, true);
// OAuth token remains transient; only an expiring observed identity is saved.
await assertOwned();
const identity = {
@@ -1303,7 +1341,7 @@ export function githubChatWizardService(
});
return resume(bot);
}
- async function directCallback(state: string, code: string) {
+ async function directCallback(state: string, code: string, actor?: AuthorizationActor) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
@@ -1315,15 +1353,16 @@ export function githubChatWizardService(
session.trustedOrigin !== origin()
)
throw forbidden("GitHub return did not match this instance");
+ const managementContext = await authorizeBrowserReturn(session, actor);
const bot = await endpoint(session.endpointId, session.userId);
if (session.status === "failed" || session.status === "exchanging")
return resume(bot);
if (session.status === "pending") {
if (!options.completeDirect)
throw conflict("GitHub registration is unavailable");
- await options.completeDirect(state, code);
+ await options.completeDirect(state, code, managementContext);
}
- const progress = await advance(bot.id, session.userId);
+ const progress = await advance(bot.id, session.userId, managementContext);
return progress.state === "install" && progress.installationUrl
? progress.installationUrl
: resume(bot);
@@ -1332,6 +1371,7 @@ export function githubChatWizardService(
state: string,
registrationId: string,
claimId?: string,
+ actor?: AuthorizationActor,
) {
const [session] = await db
.select()
@@ -1342,6 +1382,7 @@ export function githubChatWizardService(
.limit(1);
if (!session?.handoff || !claimId)
throw forbidden("GitHub return did not match this instance");
+ const managementContext = await authorizeBrowserReturn(session, actor, session.handoff.identityStateHash === hash(state));
const cloud = connector();
if (!cloud) throw conflict("Restore Paperclip Cloud enrollment");
const opened = await cloud.claimGitHubApp({
@@ -1422,7 +1463,7 @@ export function githubChatWizardService(
});
if (opened.redirectUri !== route.callbackUrls.oauth)
throw forbidden("GitHub identity callback did not match");
- return completeIdentity(session, opened.code, route.callbackUrls.oauth);
+ return completeIdentity(session, opened.code, route.callbackUrls.oauth, actor);
}
if (manifestReturn && session.status === "pending") {
await db
@@ -1437,12 +1478,12 @@ export function githubChatWizardService(
),
);
}
- const progress = await advance(session.endpointId, session.userId);
+ const progress = await advance(session.endpointId, session.userId, managementContext);
return progress.state === "install" && progress.installationUrl
? progress.installationUrl
: resume(bot);
}
- async function identityCallback(state: string, code: string) {
+ async function identityCallback(state: string, code: string, actor?: AuthorizationActor) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
@@ -1452,6 +1493,7 @@ export function githubChatWizardService(
.limit(1);
if (!session || session.trustedOrigin !== origin())
throw forbidden("GitHub identity return did not match");
+ await authorizeBrowserReturn(session, actor, true);
await endpoint(session.endpointId, session.userId);
if (!code) {
await db
@@ -1471,6 +1513,7 @@ export function githubChatWizardService(
session,
code,
`${origin()}/api/chat-github/identity/callback`,
+ actor,
);
}
async function confirmIdentity(
diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts
index 77dabac142b..42888886d0c 100644
--- a/server/src/services/connection-intents.ts
+++ b/server/src/services/connection-intents.ts
@@ -1,3 +1,4 @@
+import { connectionManagementService } from "./connection-management.js";
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { emailChannelService } from "./email-channels.js";
import { emailConnectionService } from "./email-connections.js";
@@ -13,6 +14,7 @@ import type { Db } from "@paperclipai/db";
import {
agents,
aiConnectionTaskPins,
+ chatEndpoints,
companies,
toolConnections,
connectionGrants,
@@ -370,8 +372,8 @@ export function connectionIntentService(db: Db) {
if (app.experimentalFeatureKey && !isConnectorFeatureEnabled(app, await instanceSettingsService(db).getExperimental())) {
throw notFound("Connection service was not found");
}
- if (purpose === "channel" && service !== "agentmail") {
- throw unprocessable("This email connection is not available");
+ if (purpose === "channel" && !getAvailableConnectionMethods(app).some(method => method.purpose === "channel")) {
+ throw unprocessable("This channel connection is not available");
}
const methods = purpose === "ai" ? getAvailableConnectionMethods(app).filter(method => method.transport === "runtime_auth")
: purpose === "channel" ? getAvailableConnectionMethods(app).filter(method => method.purpose === "channel")
@@ -471,9 +473,26 @@ export function connectionIntentService(db: Db) {
const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id,
responsibleUserId: run.responsibleUserId!, serviceSlug: service, purpose: aiOnly ? "ai" : service === "agentmail" ? "channel" : undefined, inventory });
const denied = !aiOnly && !ready && matching.length > 0 && await administrativeDenial(run.companyId, agent.id, service, inventory);
+ const methods = await Promise.all(app.methods.map(async method => {
+ const declaredPurpose = definition ? discoveryMethods(definition).find(candidate => candidate.key === method.key)?.purpose : undefined;
+ const purpose = declaredPurpose === "channel" ? "chat" : declaredPurpose === "ai" ? "ai" : "tool";
+ const capability = await connectionManagementService(db).capability({ type: "agent", companyId: run.companyId,
+ agentId: agent.id, runId: run.id, onBehalfOfUserId: run.responsibleUserId }, "connections:configure", {
+ type: "connection_management", companyId: run.companyId, purpose, provider: service, connectionId: ready?.id,
+ operation: "configure", selfServiceUserId: ready?.createdByUserId ?? (purpose === "ai" ? run.responsibleUserId : null),
+ });
+ const toolAccess = ready && purpose === "tool" ? await (await import("./tool-gateway.js")).createToolGatewayService(db).summarizeConnectionAccessForAgent({
+ companyId: run.companyId, connectionId: ready.id, agentId: agent.id, heartbeatRunId: run.id,
+ issueId: issue?.id,
+ }) : null;
+ return { ...method, management: { ...capability, canUse: toolAccess ? toolAccess.allowedCount > 0 : Boolean(ready),
+ requiresApproval: Boolean(toolAccess?.askFirstCount), requiresHumanAuthentication: method.auth !== "none" && !ready,
+ nextApiOperation: purpose === "chat" ? "POST /api/companies/{companyId}/chat-endpoints"
+ : purpose === "ai" ? "POST /api/companies/{companyId}/ai-connections" : "POST /api/companies/{companyId}/tools/apps/connect" } };
+ }));
candidates.push({ score, nameScore, item: {
service, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null,
- methods: app.methods, source: app.source,
+ methods, source: app.source,
state: ready ? "ready" : (denied && !isRemoteMcpConnectorId(service)) || !app.available || !app.methods.length ? "unavailable"
: matching.length ? "needs_user_action" : "available",
reason: ready ? "Connection is installed and usable by this agent" : denied ? "Ask the responsible user to grant this agent access with connection_request" : !app.available ? "Connection is disabled or unavailable"
@@ -623,7 +642,7 @@ export function connectionIntentService(db: Db) {
: !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable"
? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}`
: !suggestions && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
- : suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For available or needs_user_action tool methods or AgentMail, call connection_request with the service identifier to present its setup card. For other available or needs_user_action channel or AI methods, share the method's setupPath. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support."
+ : suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support."
: results[0]!.state === "ready" && results[0]!.methods.every(method => method.purpose === "ai")
? "AI authentication is available for this agent's next execution. Do not create another connection request or ask the user to reconnect."
: results[0]!.service === "agentmail"
@@ -631,10 +650,10 @@ export function connectionIntentService(db: Db) {
? "An active AgentMail inbox is assigned to you. Use agentmail_inboxes to read its address. Do not request another connection."
: "Call connection_request with service agentmail to show the inline API-key card. Do not send a setup link or ask for the key in chat. The card creates an inbox for this agent; wait for completion before claiming an email address."
: results[0]!.methods.every(method => method.purpose && method.purpose !== "tool")
- ? "Choose the method relevant to the task using its purpose and label. Share its setupPath with the user to open the existing channel or AI setup flow. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes."
+ ? "Choose the method relevant to the task using its purpose and label. If method.management.allowed, configure through search_api/call_api using nextApiOperation. Hand off only required human authentication or consent at the saved setupPath. These methods do not use the tool connection_request card. Do not claim tools or an inbox are ready before setup finishes."
: results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request."
: results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
- : "Call connection_request with the returned service identifier. The user already asked to connect: do not ask a generic confirmation or imitate the setup card. Follow the returned instruction." };
+ : "The user asked to connect. If method.management.allowed, use search_api/call_api with nextApiOperation to configure it. Reuse a saved draft. Use connection_request only for required human input or access. Do not ask a generic confirmation or silently change policy to bypass a failed tool call." };
}
async function providerSelections(companyId: string, issueId: string, agentId: string, userId: string, questionId: string) {
@@ -858,9 +877,43 @@ export function connectionIntentService(db: Db) {
return { ...row, interaction };
}
+ async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string) {
+ const context = await loadRunContext(claims);
+ const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.companyId, context.run.companyId)));
+ if (!endpoint || endpoint.assignedAgentId !== context.agent.id) throw forbidden("This setup is not assigned to the requesting agent");
+ await connectionManagementService(db).authorize({ type: "agent", companyId: context.run.companyId,
+ agentId: context.agent.id, runId: context.run.id, onBehalfOfUserId: context.run.responsibleUserId,
+ }, "connections:configure", { type: "connection_management", companyId: context.run.companyId,
+ connectionId: endpoint.connectionId, provider: endpoint.provider, purpose: "chat", operation: "chat.authentication_handoff" });
+ const interaction = await interactions.createConnectionIntent(context.issue, {
+ payload: { version: 1, serviceSlug: endpoint.provider, serviceName: endpoint.provider === "github" ? "GitHub" : endpoint.provider === "slack" ? "Slack" : endpoint.provider,
+ purpose: "channel", savedSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId },
+ requestingAgentId: context.agent.id, requestingAgentName: context.agent.name, phase: "authorizing" },
+ sourceRunId: context.run.id, sourceIdentityContextId: context.run.activeIdentityContextId,
+ addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-authentication:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}`,
+ });
+ if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome.");
+ return interaction.id;
+ }
+
async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise {
const loaded = await loadIntent(interactionId);
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
+ if (payload.savedSetup) {
+ const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, payload.savedSetup.endpointId), eq(chatEndpoints.companyId, loaded.issue.companyId), eq(chatEndpoints.connectionId, payload.savedSetup.connectionId)));
+ if (!endpoint || endpoint.assignedAgentId !== payload.requestingAgentId) throw notFound("Saved chat setup not found");
+ if (!loaded.interaction.addresseeUserId || endpoint.sponsorUserId !== loaded.interaction.addresseeUserId) throw notFound("Saved chat setup not found");
+ await connectionManagementService(db).authorize({ type: "board", userId: loaded.interaction.addresseeUserId, source: "session", ignoreInstanceAdmin: true }, "connections:configure", {
+ type: "connection_management", companyId: loaded.issue.companyId, connectionId: endpoint.connectionId,
+ targetAgentId: endpoint.assignedAgentId, purpose: "chat", operation: "chat.authentication_handoff",
+ });
+ const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, loaded.issue.companyId));
+ return { version: 1, interaction: loaded.interaction, requestedAgentId: payload.requestingAgentId, existingConnections: [],
+ service: { service: payload.serviceSlug, name: payload.serviceName, description: null, logoUrl: null, methods: [], state: "needs_user_action", connectionId: endpoint.connectionId },
+ chatSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId,
+ path: `/${encodeURIComponent(company!.prefix)}/apps/chat/connect?${new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id })}` },
+ };
+ }
const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId, payload.purpose);
if (payload.purpose === "channel") {
const [saved] = await db.select().from(toolConnections).where(and(
@@ -1203,6 +1256,7 @@ export function connectionIntentService(db: Db) {
usableConnectionForAgent,
search,
request,
+ requestChatHandoff,
// Controller-only entry point. Runtime tokens still require a running run.
requestForRunAuthFailure: async (runId: string) => {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
diff --git a/server/src/services/connection-management-policy.ts b/server/src/services/connection-management-policy.ts
new file mode 100644
index 00000000000..ee971ebb907
--- /dev/null
+++ b/server/src/services/connection-management-policy.ts
@@ -0,0 +1,39 @@
+import type { PermissionKey } from "@paperclipai/shared";
+
+/** Domain actions use the ordinary grant evaluator, including its user ceiling. */
+export const CONNECTION_MANAGEMENT_PERMISSIONS = {
+ "connections:read": "tools:manage_connections",
+ "connections:configure": "tools:manage_connections",
+ "connections:profiles": "tools:manage_profiles",
+ "connections:gateway": "tools:manage_connections",
+ "connections:audit": "tools:view_audit",
+ "connections:runtime": "tools:manage_runtime",
+ "connections:test": "tools:use",
+} as const satisfies Record;
+
+export type ConnectionManagementAction = keyof typeof CONNECTION_MANAGEMENT_PERMISSIONS;
+
+export function isConnectionManagementAction(action: string): action is ConnectionManagementAction {
+ return Object.hasOwn(CONNECTION_MANAGEMENT_PERMISSIONS, action);
+}
+
+/** Values come from resolved database rows, never caller-selected identities. */
+export interface ConnectionManagementResource {
+ type: "connection_management";
+ companyId: string;
+ connectionId?: string | null;
+ profileId?: string | null;
+ gatewayId?: string | null;
+ provider?: string | null;
+ purpose?: "tool" | "chat" | "ai";
+ operation: string;
+ readOnly?: boolean;
+ targetAgentId?: string | null;
+ projectId?: string | null;
+ issueId?: string | null;
+ /** Personal self-service/creator authority, only for operations that permit it. */
+ selfServiceUserId?: string | null;
+ /** Resolved installation diff changes only agent targets, preserving company sharing. */
+ selfServiceAgentInstalls?: boolean;
+ requestedInstallCompanyIds?: string[];
+}
diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts
new file mode 100644
index 00000000000..adeebeab55a
--- /dev/null
+++ b/server/src/services/connection-management.ts
@@ -0,0 +1,215 @@
+import { and, eq, or } from "drizzle-orm";
+import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, type Db } from "@paperclipai/db";
+import { forbidden } from "../errors.js";
+import { authorizationService, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js";
+import { captureRunIdentity } from "./run-identity.js";
+import type { LogActivityInput } from "./activity-log.js";
+import type { ConnectionManagementAction, ConnectionManagementResource } from "./connection-management-policy.js";
+
+export type ConnectionManagementContext = {
+ actor: AuthorizationActor;
+ responsibleUserId: string | null;
+ resource: ConnectionManagementResource;
+ decision: AuthorizationDecision;
+ audit: Pick;
+ reauthorize: (continuation?: { afterHumanAction: true }) => Promise;
+};
+
+export function responsibleConnectionUser(actor: AuthorizationActor): string | null {
+ return (actor.type === "agent" ? actor.onBehalfOfUserId : actor.userId) ?? null;
+}
+
+/** Used at HTTP and service boundaries; capability probes call the same evaluator. */
+export function connectionManagementService(db: Db) {
+ const authorization = authorizationService(db);
+
+ async function authorize(
+ actor: AuthorizationActor,
+ action: ConnectionManagementAction,
+ resource: ConnectionManagementResource,
+ options: { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean } = {},
+ ): Promise {
+ const responsibleUserId = responsibleConnectionUser(actor);
+ if (actor.type === "agent") {
+ if (!responsibleUserId || !actor.agentId || actor.companyId !== resource.companyId) {
+ throw forbidden("Connection management requires a responsible user in this company");
+ }
+ const [agent] = await db.select().from(agents).where(and(eq(agents.id, actor.agentId), eq(agents.companyId, resource.companyId)));
+ if (!agent || ["terminated", "pending_approval", "paused"].includes(agent.status)) throw forbidden("Agent is not active");
+ if (actor.keyId) {
+ const [key] = await db.select().from(agentApiKeys).where(and(eq(agentApiKeys.id, actor.keyId), eq(agentApiKeys.agentId, actor.agentId), eq(agentApiKeys.companyId, resource.companyId)));
+ if (!key || key.revokedAt || key.responsibleUserId !== responsibleUserId || (key.scopeConfig && key.scopeConfig.kind !== "standard")) throw forbidden("Agent key authority is no longer valid");
+ }
+ if (actor.runId) {
+ const captured = options.afterHumanAction ? await (async () => {
+ const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId!), eq(heartbeatRuns.agentId, actor.agentId!), eq(heartbeatRuns.companyId, resource.companyId)));
+ if (!run || !["running", "succeeded"].includes(run.status)) throw forbidden("The originating run cannot continue this authentication");
+ const [pending] = await db.select().from(runIdentityContexts).where(and(eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending")));
+ if (pending) throw forbidden("Run identity is being reconciled");
+ return { run, context: null };
+ })() : await captureRunIdentity(db, { companyId: resource.companyId, agentId: actor.agentId, runId: actor.runId });
+ const { run, context } = captured;
+ if ((context?.responsibleUserId ?? run.responsibleUserId) !== responsibleUserId) throw forbidden("Run responsible-user authority has changed");
+ const snapshot = run.contextSnapshot ?? {};
+ const issueId = run.issueId ?? (typeof snapshot.issueId === "string" ? snapshot.issueId : typeof snapshot.taskId === "string" ? snapshot.taskId : null);
+ const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, resource.companyId))) : [];
+ if (issueId && (!issue || issue.assigneeAgentId !== actor.agentId || ["cancelled", "done"].includes(issue.status))) throw forbidden("Run no longer owns an eligible task");
+ if (snapshot.paperclipSkillTest || issue?.workMode === "skill_test") throw forbidden("Skill tests cannot administer connections");
+ if ((options.mutation ?? action !== "connections:read") && issue && issue.workMode !== "standard") throw forbidden("Connection changes are unavailable in Ask and Plan modes");
+ }
+ }
+ const expandedTargets = new Set(options.targetAgentIds ?? []);
+ const referencedConnections: string[] = [];
+ let currentGatewayResource: ConnectionManagementResource | undefined;
+ if (resource.gatewayId && !options.gatewayContextChecked) {
+ const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, resource.gatewayId), eq(toolMcpGateways.companyId, resource.companyId)));
+ if (!gateway) throw forbidden("Gateway is unavailable in this company");
+ // Explicit null is the proposed cleared binding, not an omitted selector.
+ resource = { ...resource,
+ profileId: Object.hasOwn(resource, "profileId") ? resource.profileId : gateway.profileId,
+ projectId: Object.hasOwn(resource, "projectId") ? resource.projectId : gateway.projectId,
+ issueId: Object.hasOwn(resource, "issueId") ? resource.issueId : gateway.issueId,
+ };
+ if (gateway.agentId) expandedTargets.add(gateway.agentId);
+ // Authorize both sides of a binding change. The saved side is read afresh
+ // on reauthorization, even when the request context holds an older binding.
+ if (resource.profileId !== gateway.profileId || resource.projectId !== gateway.projectId || resource.issueId !== gateway.issueId) {
+ currentGatewayResource = { ...resource, profileId: gateway.profileId, projectId: gateway.projectId, issueId: gateway.issueId };
+ }
+ }
+ if (resource.profileId && !options.expandedProfile) {
+ const [profile] = await db.select().from(toolProfiles).where(and(eq(toolProfiles.id, resource.profileId), eq(toolProfiles.companyId, resource.companyId)));
+ if (!profile) throw forbidden("Profile is unavailable in this company");
+ const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id));
+ for (const binding of bindings) {
+ if (binding.targetType === "agent") expandedTargets.add(binding.targetId);
+ if (binding.targetType === "company") {
+ const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
+ companyAgents.forEach(agent => expandedTargets.add(agent.id));
+ }
+ if (binding.targetType === "project") {
+ await authorize(actor, action, { ...resource, projectId: binding.targetId }, { ...options, expandedProfile: true, gatewayContextChecked: options.gatewayContextChecked || Boolean(resource.gatewayId) });
+ }
+ }
+ const entries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id));
+ for (const entry of entries) if (entry.connectionId) referencedConnections.push(entry.connectionId);
+ }
+ if (resource.connectionId) {
+ const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, resource.connectionId), eq(toolConnections.companyId, resource.companyId)));
+ if (!connection) throw forbidden("Connection is unavailable in this company");
+ resource = { ...resource, selfServiceUserId: connection.createdByUserId,
+ purpose: connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool",
+ provider: typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey
+ : typeof connection.config.provider === "string" ? connection.config.provider : resource.provider,
+ };
+ if (options.mutation ?? action !== "connections:read") {
+ const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
+ for (const install of installs) {
+ if (install.targetType === "agent") expandedTargets.add(install.targetId);
+ if (install.targetType === "company") {
+ const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
+ rows.forEach(agent => expandedTargets.add(agent.id));
+ }
+ }
+ if (resource.requestedInstallCompanyIds) {
+ const current = new Set(installs.filter(install => install.targetType === "company").map(install => install.targetId));
+ resource = { ...resource, selfServiceAgentInstalls: current.size === resource.requestedInstallCompanyIds.length && resource.requestedInstallCompanyIds.every(id => current.has(id)) };
+ }
+ }
+ if (connection.connectionPurpose === "ai") {
+ const [pool] = await db.select().from(aiConnectionPools).where(eq(aiConnectionPools.id, connection.id));
+ for (const member of pool?.config.members ?? []) referencedConnections.push(member.binding.connectionId);
+ }
+ if (responsibleUserId && (connection.credentialPolicy === "per_user" || resource.selfServiceAgentInstalls || resource.operation.includes("/grants/"))) {
+ const [ownGrant] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
+ eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
+ eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, responsibleUserId),
+ ));
+ if (ownGrant) resource.selfServiceUserId = responsibleUserId;
+ }
+ if (resource.selfServiceAgentInstalls) {
+ const [access] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
+ eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
+ or(eq(connectionGrants.kind, "organization"), and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, responsibleUserId ?? ""))),
+ ));
+ if (!access && resource.selfServiceUserId !== responsibleUserId) throw forbidden("You need access to this connection before you can install it on an agent");
+ }
+ if (actor.type === "agent" && connection.credentialPolicy === "per_user" && connection.createdByUserId !== responsibleUserId) {
+ const [grant] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
+ eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
+ eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, responsibleUserId!),
+ ));
+ if (!grant) throw forbidden("This personal connection is unavailable to the responsible user");
+ }
+ }
+ if (actor.type === "agent") {
+ if (resource.projectId) {
+ const [project] = await db.select().from(projects).where(and(eq(projects.id, resource.projectId), eq(projects.companyId, resource.companyId)));
+ if (!project || !(await canActorReadProjectPrivacy(db, actor, project))) throw forbidden("Project is unavailable to this delegated principal");
+ }
+ if (resource.issueId) {
+ const [issue] = await db.select().from(issues).where(and(eq(issues.id, resource.issueId), eq(issues.companyId, resource.companyId)));
+ if (!issue || !(await canActorReadIssuePrivacy(db, actor, issue))) throw forbidden("Task is unavailable to this delegated principal");
+ if (issue.workMode === "skill_test" || ((options.mutation ?? action !== "connections:read") && issue.workMode !== "standard")) throw forbidden("Connection changes require a standard task");
+ }
+ }
+ const targets = [...new Set([...expandedTargets, ...(resource.targetAgentId ? [resource.targetAgentId] : [])])];
+ let decision: AuthorizationDecision | undefined;
+ for (const targetAgentId of targets.length ? targets : [undefined]) {
+ const resolved = { ...resource, ...(targetAgentId ? { targetAgentId } : {}) };
+ decision = await authorization.decide({ actor, action, resource: resolved, scope: { ...resolved } });
+ if (!decision.allowed) throw forbidden(decision.explanation, { reason: decision.reason, code: decision.code, operation: resource.operation });
+ if (actor.type === "agent" && actor.runId && (resolved.projectId || resolved.issueId)) {
+ const resourceTrust = await authorization.decide({ actor: { ...actor, runId: undefined }, action, resource: resolved, scope: { ...resolved } });
+ if (!resourceTrust.allowed) throw forbidden(resourceTrust.explanation);
+ }
+ if (targetAgentId) {
+ const [target] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, targetAgentId), eq(agents.companyId, resource.companyId)));
+ if (!target) throw forbidden("Target agent is outside this company");
+ // Explicitly enforce both principals even when unrelated authorization is in shadow mode.
+ // Existing human routes retain their domain-specific target authority.
+ // Delegation additionally checks both the acting agent and its human.
+ const principals: AuthorizationActor[] = actor.type === "agent" || resource.selfServiceAgentInstalls ? [actor] : [];
+ if (actor.type === "agent") principals.push({ type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true });
+ for (const principal of principals) {
+ const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } });
+ if (!targetDecision.allowed) throw forbidden(targetDecision.explanation);
+ }
+ }
+ }
+ if (action === "connections:profiles" && resource.requestedInstallCompanyIds && !resource.selfServiceAgentInstalls) {
+ await authorize(actor, "connections:configure", resource, options);
+ }
+ for (const connectionId of new Set(referencedConnections)) {
+ if (connectionId !== resource.connectionId) await authorize(actor, action, { ...resource, connectionId }, { ...options, targetAgentIds: [...expandedTargets], expandedProfile: true, gatewayContextChecked: options.gatewayContextChecked || Boolean(resource.gatewayId) });
+ }
+ if (currentGatewayResource) {
+ await authorize(actor, action, currentGatewayResource, { ...options, targetAgentIds: [...expandedTargets], expandedProfile: false, gatewayContextChecked: true });
+ }
+ return {
+ actor, responsibleUserId, resource, decision: decision!,
+ audit: { authorization: { action, operation: resource.operation, reason: decision!.reason }, actorType: actor.type === "agent" ? "agent" : "user", actorId: actor.type === "agent" ? actor.agentId! : actor.userId ?? "board",
+ agentId: actor.agentId ?? null, runId: actor.runId ?? null, agentApiKeyId: actor.keyId ?? null, responsibleUserIdOverride: responsibleUserId },
+ reauthorize: async (continuation) => { await authorize(actor, action, resource, { ...options, afterHumanAction: continuation?.afterHumanAction ?? options.afterHumanAction }); },
+ };
+ }
+
+ async function capability(...args: Parameters) {
+ try {
+ const context = await authorize(...args);
+ return { allowed: true, reason: context.decision.reason, operation: args[2].operation };
+ } catch (error) {
+ // Database/provider faults are not authorization denials.
+ if (!(error instanceof Error && "status" in error && error.status === 403)) throw error;
+ return { allowed: false, reason: error.message, operation: args[2].operation };
+ }
+ }
+ return { authorize, capability };
+}
+
+/** A pending provider login can finish after a successful yielding run. This
+ * keeps its original actor, user, task, trust and grant checks; it is never an
+ * option accepted from an HTTP caller. Cancelled tasks/runs remain ineligible. */
+export function continuingConnectionManagement(context?: ConnectionManagementContext): ConnectionManagementContext | undefined {
+ return context && { ...context, reauthorize: () => context.reauthorize({ afterHumanAction: true }) };
+}
diff --git a/server/src/services/connectors/slack/setup/registration.ts b/server/src/services/connectors/slack/setup/registration.ts
index ca44f0e7f50..9b6d87ec941 100644
--- a/server/src/services/connectors/slack/setup/registration.ts
+++ b/server/src/services/connectors/slack/setup/registration.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "../../../connection-management.js";
import { notifyChatVerificationWork } from "../../../chat-work-notifications.js";
import { createAgentAvatarPool } from "../../../agent-avatar-pool.js";
import type { AgentAvatarRequest } from "../../../agent-avatars.js";
@@ -12,7 +13,7 @@ import { secretService } from "../../../secrets.js";
import { writeConnectionCredential } from "../../../connection-credentials.js";
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
-export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean }
+export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean; managementContext?: ConnectionManagementContext }
export type Registration = typeof chatSlackRegistrations.$inferSelect;
@@ -59,6 +60,7 @@ export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions)
return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0];
}
async function endpoint(endpointId: string, actor: SlackSetupActor) {
+ await actor.managementContext?.reauthorize();
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance })
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
@@ -76,13 +78,14 @@ export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions)
throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup.");
}
async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) {
- await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId,
+ await logActivity(db, { companyId: row.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }),
action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId,
details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } });
}
const api = createSlackSetupClient(fetchImpl);
async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
patch: Partial = {}, account?: SlackAccountState) {
+ await actor.managementContext?.reauthorize();
await db.transaction(async tx => {
await lease.assertOwned(tx);
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts
index 47996f0acf5..51e9b08a6f3 100644
--- a/server/src/services/connectors/slack/test-message.ts
+++ b/server/src/services/connectors/slack/test-message.ts
@@ -1,3 +1,4 @@
+import { toolAccessPolicyService } from "../../tool-access-policy.js";
import { and, eq, gte, inArray, ne } from "drizzle-orm";
import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db";
import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared";
@@ -19,6 +20,7 @@ const human = (user: Record, workspaceId: string) =>
export function slackTestMessageService(db: Db, options: Options) {
const api = createSlackSetupClient(options.fetch);
return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise => {
+ await actor.managementContext?.reauthorize();
const input = slackTestMessageSchema.parse(rawInput);
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
return options.withLock(endpointId, async lease => {
@@ -34,6 +36,24 @@ export function slackTestMessageService(db: Db, options: Options) {
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"),
));
if (!membership) throw forbidden("An active Paperclip account is required");
+ async function authorizeTestMessage() {
+ const context = actor.managementContext;
+ if (context?.actor.type !== "agent") return;
+ await context.reauthorize();
+ const policy = toolAccessPolicyService(db);
+ const request = {
+ companyId: endpoint.companyId,
+ actor: { actorType: "agent" as const, actorId: context.actor.agentId!, agentId: endpoint.assignedAgentId },
+ runContext: { heartbeatRunId: context.actor.runId ?? null },
+ request: { connectionId: endpoint.connectionId, providerType: "slack", applicationKey: "slack",
+ toolName: "slack.send_message", upstreamToolName: "chat.postMessage", riskLevel: "write" as const,
+ sideEffecting: true, arguments: { purpose: "setup_test", recipient: actor.userId } },
+ };
+ const decision = await policy.decide(request);
+ await policy.writeAudit(request, decision);
+ if (!decision.allowed) throw forbidden(decision.explanation, { reasonCode: decision.reasonCode,
+ requiredUserAction: decision.decision === "require_approval" ? { kind: "approve", reason: "Request this message through the normal Slack tool approval flow." } : undefined });
+ }
const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`;
const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey)));
if (previous) {
@@ -86,6 +106,7 @@ export function slackTestMessageService(db: Db, options: Options) {
if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message.");
return { status: "connection_pending", principalId, expiresAt };
}
+ await authorizeTestMessage();
// The account holder must follow the private Slack challenge and sign in.
// Selecting a workspace member does not prove identity ownership.
const intent = await options.createLinkIntent(endpointId, principalId, 600);
@@ -104,12 +125,13 @@ export function slackTestMessageService(db: Db, options: Options) {
await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id));
throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another.");
}
- try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
+ try { await logActivity(db, { companyId: endpoint.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }), action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); }
return { status: "connection_pending", principalId, expiresAt: intent.expiresAt };
}
+ await authorizeTestMessage();
await options.linkInstaller(endpointId, user, actor, lease);
- await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } });
+ await logActivity(db, { companyId: endpoint.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }), action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } });
await lease.assertOwned();
// Persist before dispatch: a crash or ambiguous response must never replay the DM.
const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey,
@@ -126,7 +148,7 @@ export function slackTestMessageService(db: Db, options: Options) {
await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id));
throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message.");
}
- try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
+ try { await logActivity(db, { companyId: endpoint.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }), action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); }
catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); }
return { status: "sent", slackUrl };
});
diff --git a/server/src/services/device-login-service.ts b/server/src/services/device-login-service.ts
index b409bef2c4f..c5ee52a96ff 100644
--- a/server/src/services/device-login-service.ts
+++ b/server/src/services/device-login-service.ts
@@ -1,5 +1,6 @@
import { writeLoginCleanup } from "./login-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
+import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -148,6 +149,7 @@ export interface LoginSessionRuntime {
* session, the company, the owner, and the adapter, so the promotion resolves
* the company slot and the sole-active-owner check for this exact session. */
export interface CredentialPromotionContext {
+ managementContext?: ConnectionManagementContext;
sessionId: string;
companyId: string;
startedByUserId: string;
@@ -210,6 +212,7 @@ export interface LoginSessionActivityEvent {
export type LoginSessionActivityRecorder = (event: LoginSessionActivityEvent) => void;
export interface StartDeviceLoginInput {
+ managementContext?: ConnectionManagementContext;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
environmentId: string;
@@ -915,6 +918,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
async function start(
input: StartDeviceLoginInput,
): Promise {
+ await input.managementContext?.reauthorize();
const sessionId = randomUUID();
// The public session identifier the API returns and looks up. It is an
// independent CSPRNG value, so it never equals the internal `sessionId` and a
@@ -1124,7 +1128,9 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
if (!credential || credential.length === 0) {
throw new Error("missing_credential");
}
+ await input.managementContext?.reauthorize();
await profile.promotion.promote(credential, {
+ managementContext: input.managementContext,
sessionId,
companyId: input.companyId,
startedByUserId: input.startedByUserId,
@@ -1159,12 +1165,14 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
resultClaim: resultClaim ?? null,
});
const promotionContext: CredentialPromotionContext = {
+ managementContext: input.managementContext,
sessionId,
companyId: input.companyId,
startedByUserId: input.startedByUserId,
adapterType: input.adapterType,
};
try {
+ await input.managementContext?.reauthorize();
return profile.promotion.runTerminalCommit
? await profile.promotion.runTerminalCommit(commitAuthenticated, promotionContext)
: await commitAuthenticated();
diff --git a/server/src/services/email-channels.ts b/server/src/services/email-channels.ts
index 688aa2d6cda..782a53f10d1 100644
--- a/server/src/services/email-channels.ts
+++ b/server/src/services/email-channels.ts
@@ -1,3 +1,4 @@
+import type { ConnectionManagementContext } from "./connection-management.js";
import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { fastResponseService, enqueueFastResponse, fastResponseSourceCurrent, fastResponseTurnQueued } from "./fast-responses.js";
@@ -72,6 +73,7 @@ import {
} from "./agentmail-api.js";
export type EmailActor = {
+ managementContext?: ConnectionManagementContext;
userId?: string;
agentId?: string;
runId?: string;
@@ -277,6 +279,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
companyId: endpoint.companyId,
actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system",
actorId: actor.agentId ?? actor.userId ?? "agentmail",
+ ...actor.managementContext?.audit,
action,
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -627,6 +630,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
input: EmailEndpointSetupInput,
actor: EmailActor,
) {
+ await actor.managementContext?.reauthorize();
const [agent] = await db
.select()
.from(agents)
@@ -684,6 +688,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
)
throw conflict("Setup request already belongs to another inbox");
if (!endpoint) {
+ await actor.managementContext?.reauthorize();
const applicationId = input.applicationId ?? randomUUID();
const connectionId = randomUUID();
await db.transaction(async (tx) => {
@@ -725,6 +730,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
authKind: "api_key",
ownership: "customer",
credentialPolicy: "shared",
+ createdByUserId: actor.userId ?? null,
status: "draft",
enabled: false,
config: { provider: "agentmail" },
@@ -831,6 +837,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
});
if (scope.scope_type === "inbox" && scope.inbox_id !== inbox.inbox_id)
throw forbidden("API key belongs to a different inbox");
+ await actor.managementContext?.reauthorize();
await db
.update(chatEndpoints)
.set({
@@ -851,6 +858,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
if (!config.ownedApiKeyId && scope.scope_type !== "inbox") {
const key = await api.createInboxKey(inbox.inbox_id);
try {
+ await actor.managementContext?.reauthorize();
await vault(endpoint, "apiKey", key.api_key);
await db
.update(emailEndpoints)
@@ -881,6 +889,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
);
const webhook = await createWebhook({ ...endpoint, botExternalId: inbox.inbox_id }, api);
try {
+ await actor.managementContext?.reauthorize();
await vault(endpoint, "webhookSecret", webhook.secret);
await db
.update(emailEndpoints)
@@ -891,6 +900,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
throw error;
}
}
+ await actor.managementContext?.reauthorize();
const now = new Date();
await db.transaction(async (tx) => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.email);
@@ -2375,6 +2385,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
action: "pause" | "resume" | "remove",
actor: EmailActor,
) {
+ await actor.managementContext?.reauthorize();
const endpoint = await getEndpoint(id);
const result = await withLease(endpoint, async () => {
const config = await getConfig(id);
@@ -2387,6 +2398,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
endpoint.botExternalId!,
);
}
+ await actor.managementContext?.reauthorize();
if (action !== "resume") await stopEndpoint(endpoint);
let cleanupError: string | null = null;
if (action === "remove") {
@@ -2477,6 +2489,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
receiveMode: "websocket" | "webhook",
actor: EmailActor,
) {
+ await actor.managementContext?.reauthorize();
const endpoint = await getEndpoint(id);
if (endpoint.status === "archived" || !endpoint.botExternalId)
throw conflict("Create a new inbox connection");
@@ -2488,6 +2501,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
)
throw forbidden("API key belongs to a different inbox");
await api.getInbox(endpoint.botExternalId);
+ await actor.managementContext?.reauthorize();
const result = await withLease(endpoint, async () => {
const config = await getConfig(id);
// Check registration permissions before interrupting a working socket.
@@ -2496,6 +2510,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
: null;
if (preparedWebhook) {
try {
+ await actor.managementContext?.reauthorize();
await vault(endpoint, "webhookSecret", preparedWebhook.secret);
await db.update(emailEndpoints).set({ webhookId: preparedWebhook.webhook_id })
.where(eq(emailEndpoints.endpointId, id));
@@ -2504,6 +2519,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
throw error;
}
}
+ await actor.managementContext?.reauthorize();
await stopEndpoint(endpoint);
// Only registrations and scoped keys created by Paperclip are removed.
if (config.webhookId)
diff --git a/server/src/services/email-connections.ts b/server/src/services/email-connections.ts
index 78f38ad5058..72f5498f4ff 100644
--- a/server/src/services/email-connections.ts
+++ b/server/src/services/email-connections.ts
@@ -176,7 +176,9 @@ export function emailConnectionService(
input: EmailConnectionInput,
actor: EmailActor,
) {
+ await actor.managementContext?.reauthorize();
const identity = await agentmailApi(input.apiKey, fetchImpl).whoami();
+ await actor.managementContext?.reauthorize();
return db.transaction(async (tx) => {
const db = tx as unknown as Db;
await db.execute(
@@ -252,8 +254,9 @@ export function emailConnectionService(
],
},
{
- actorType: "user",
- actorId: actor.userId ?? "board",
+ actorType: actor.agentId ? "agent" : "user",
+ actorId: actor.agentId ?? actor.userId ?? "board",
+ managementContext: actor.managementContext,
actorSource: actor.localImplicit ? "local_implicit" : "session",
},
);
@@ -286,8 +289,9 @@ export function emailConnectionService(
})),
},
{
- actorType: "user",
- actorId: actor.userId ?? "board",
+ actorType: actor.agentId ? "agent" : "user",
+ actorId: actor.agentId ?? actor.userId ?? "board",
+ managementContext: actor.managementContext,
actorSource: actor.localImplicit ? "local_implicit" : "session",
},
);
@@ -298,8 +302,9 @@ export function emailConnectionService(
}).where(eq(toolConnections.id, connection.id));
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: actor.userId ?? "board",
+ actorType: actor.agentId ? "agent" : "user",
+ actorId: actor.agentId ?? actor.userId ?? "board",
+ ...actor.managementContext?.audit,
action: "email.connection.created",
entityType: "tool_connection",
entityId: connection.id,
@@ -357,15 +362,17 @@ export function emailConnectionService(
],
},
{
- actorType: "user",
- actorId: actor.userId ?? "board",
+ actorType: actor.agentId ? "agent" : "user",
+ actorId: actor.agentId ?? actor.userId ?? "board",
+ managementContext: actor.managementContext,
actorSource: actor.localImplicit ? "local_implicit" : "session",
},
);
await logActivity(db, {
companyId,
- actorType: "user",
- actorId: actor.userId ?? "board",
+ actorType: actor.agentId ? "agent" : "user",
+ actorId: actor.agentId ?? actor.userId ?? "board",
+ ...actor.managementContext?.audit,
action: replaceInitialAccess ? "email.connection.agents_updated" : "email.connection.agent_added",
entityType: "tool_connection",
entityId: id,
diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts
index 3da1fa60ec7..6de405ef449 100644
--- a/server/src/services/local-ai-login.ts
+++ b/server/src/services/local-ai-login.ts
@@ -1,4 +1,5 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
+import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
@@ -83,10 +84,11 @@ export function localAiLoginService(db: Db) {
}
}
- async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false): Promise {
+ async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false, managementContext?: ConnectionManagementContext): Promise {
if (intent.provider !== "openai" && intent.provider !== "xai" && intent.provider !== "anthropic")
throw unprocessable("This provider does not use a separate local login home.");
await reapExpired();
+ await managementContext?.reauthorize();
const attempt = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-local-login:${companyId}:${userId}:${intent.provider}`}, 0))`);
const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local";
@@ -109,7 +111,7 @@ export function localAiLoginService(db: Db) {
await tx.update(adapterAuthSessions).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(eq(adapterAuthSessions.id, existing.id));
await logActivity(tx as unknown as Db, {
- companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled",
+ companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_cancelled",
entityType: "adapter_auth_session", entityId: existing.id, details: { provider: intent.provider },
});
}
@@ -129,7 +131,7 @@ export function localAiLoginService(db: Db) {
connectionMethod: LOCAL_LOGIN_METHOD, status: "waiting_for_user", expiresAt,
});
await logActivity(tx as unknown as Db, {
- companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_started",
+ companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_started",
entityType: "adapter_auth_session", entityId: id, details: { provider: intent.provider },
});
} catch (error) {
@@ -201,7 +203,8 @@ export function localAiLoginService(db: Db) {
});
}
- async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent) {
+ async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
const result = await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
@@ -222,7 +225,7 @@ export function localAiLoginService(db: Db) {
// Nested transaction is a savepoint on this same connection. Holding the
// attempt lock makes completion/cancellation/restart retries idempotent.
const saved = await aiConnectionService(tx as unknown as Db)
- .save(companyId, userId, intent, credential, { id }, session.createdAt);
+ .save(companyId, userId, intent, credential, { id }, session.createdAt, managementContext);
await tx.update(adapterAuthSessions).set({
status: "authenticated", connectionMethod: LOCAL_LOGIN_METHOD,
finishedAt: new Date(), updatedAt: new Date(),
@@ -235,7 +238,8 @@ export function localAiLoginService(db: Db) {
return result;
}
- async function cancel(companyId: string, userId: string, id: string) {
+ async function cancel(companyId: string, userId: string, id: string, managementContext?: ConnectionManagementContext) {
+ await managementContext?.reauthorize();
await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
@@ -250,7 +254,7 @@ export function localAiLoginService(db: Db) {
status: "cancelled", finishedAt: new Date(), updatedAt: new Date(),
}).where(eq(adapterAuthSessions.id, id));
await logActivity(tx as unknown as Db, {
- companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled",
+ companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_cancelled",
entityType: "adapter_auth_session", entityId: id,
details: { provider: session.aiConnection?.provider },
});
diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts
index 5033a29dd9c..7cf47fc34df 100644
--- a/server/src/services/native-runtime/runner-api-catalog.ts
+++ b/server/src/services/native-runtime/runner-api-catalog.ts
@@ -89,9 +89,9 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
- const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
+ const protocol = operation["x-paperclip-transport"] === "protocol" || (operation["x-paperclip-transport"] !== "rest" && (!path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
- || /event-stream|websocket/i.test(JSON.stringify(operation.responses));
+ || /event-stream|websocket/i.test(JSON.stringify(operation.responses))));
result.push({
operationId: `${method} ${path}`, method, path,
summary: operation.summary ?? `${method} ${path}`,
@@ -114,7 +114,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions."
: "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.",
- allowedModes: method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
+ allowedModes: operation["x-paperclip-connection-management"] ? (method === "GET" ? ["standard", "ask", "planning"] : ["standard"]) : method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
diff --git a/server/src/services/native-runtime/runner-api-sensitive-response.ts b/server/src/services/native-runtime/runner-api-sensitive-response.ts
index 1920d27e239..c135df2c852 100644
--- a/server/src/services/native-runtime/runner-api-sensitive-response.ts
+++ b/server/src/services/native-runtime/runner-api-sensitive-response.ts
@@ -1,4 +1,5 @@
export function runnerApiSensitiveResponse(method: string, path: string): boolean {
+ if (/(\/apps\/connect$|\/email\/connections$|setup-secret|\/github\/(setup|identity\/start)|\/slack\/resume|start-authorization|finalize-oauth-access|\/oauth\/|\/registration|\/managed\/authorize|\/install$|\/login|\/setup-token-login-sessions|\/local\/attempts)/.test(path)) return true;
if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true;
return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path)
|| /^\/api\/companies\/\{[^}]+\}\/exports?(?:\/|$)/.test(path)
diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts
index 59eec13aff6..b32db58ef49 100644
--- a/server/src/services/native-runtime/runner-api.test.ts
+++ b/server/src/services/native-runtime/runner-api.test.ts
@@ -11,6 +11,7 @@ import {
validateRunnerApiCall,
type RunnerApiIo,
} from "./runner-api-client.js";
+import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js";
import { RUNNER_API_RESPONSE_MAX_BYTES } from "./runner-api-response-limits.js";
const context = {
@@ -57,6 +58,22 @@ describe("bounded response capture receipts", () => {
});
});
+describe("connection setup response protection", () => {
+ it.each([
+ ["POST", "/api/chat-endpoints/{endpointId}/setup-secret"],
+ ["POST", "/api/chat-endpoints/{endpointId}/github/setup"],
+ ["POST", "/api/chat-endpoints/{endpointId}/slack/resume"],
+ ["POST", "/api/companies/{companyId}/adapters/{type}/login-sessions"],
+ ["GET", "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/prompt"],
+ ["POST", "/api/tool-gateway/gateways/{gatewayId}/tokens"],
+ ])("protects %s %s receipts and history", (method, path) => {
+ expect(runnerApiSensitiveResponse(method, path)).toBe(true);
+ });
+ it("keeps ordinary connection metadata shareable", () => {
+ expect(runnerApiSensitiveResponse("GET", "/api/tool-connections/{connectionId}")).toBe(false);
+ });
+});
+
describe("runner API catalog", () => {
it("discovers the real handoff schema and recovery endpoints with standard-mode mutation authority", () => {
const operation = runnerApiOperation("POST /api/companies/{companyId}/case-handoffs");
@@ -197,8 +214,6 @@ describe("runner API request boundary", () => {
it.each([
"POST /api/mcp/project-tools",
"POST /api/agents/{id}/claude-login",
- "POST /api/companies/{companyId}/adapters/{type}/login-sessions",
- "POST /api/agents/me/connections/{connectionId}/start-authorization",
])(
"directs authentication handshake %s to its existing client",
async (operationId) => {
@@ -210,6 +225,16 @@ describe("runner API request boundary", () => {
expect(request).not.toHaveBeenCalled();
},
);
+ it.each([
+ "POST /api/companies/{companyId}/adapters/{type}/login-sessions",
+ "POST /api/agents/me/connections/{connectionId}/start-authorization",
+ ])("calls JSON authentication initiation through REST: %s", async operationId => {
+ const request = vi.fn(async () => Response.json({ error: "Route permission required" }, { status: 403 }));
+ expect(runnerApiOperation(operationId).transport).toBe("rest");
+ const result = await executeRunnerApi({ operationId, pathParams: operationId.includes("{type}") ? { type: "codex_local" } : { connectionId: "connection-a" }, body: {} }, context, io(request));
+ expect(result).toMatchObject({ status: 403 });
+ expect(request).toHaveBeenCalledOnce();
+ });
it.each(
runnerApiCatalog().filter(
(operation) =>
diff --git a/server/src/services/satisfied-connection-intents.ts b/server/src/services/satisfied-connection-intents.ts
index 61b7bb21783..15db040b411 100644
--- a/server/src/services/satisfied-connection-intents.ts
+++ b/server/src/services/satisfied-connection-intents.ts
@@ -1,6 +1,7 @@
import { enqueueTerminalIssueInteractionChatPublications } from "./chat-interaction-publications.js";
+import { connectionManagementService } from "./connection-management.js";
import { and, asc, eq, gt, inArray, isNull, or } from "drizzle-orm";
-import { approvals, companyMemberships, connectionIntentDeliveries, issueApprovals, issueThreadInteractions, issues, type Db } from "@paperclipai/db";
+import { approvals, chatEndpoints, toolConnections, companyMemberships, connectionIntentDeliveries, issueApprovals, issueThreadInteractions, issues, type Db } from "@paperclipai/db";
import { connectionIntentPayloadSchema } from "@paperclipai/shared";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
import { connectionIntentService } from "./connection-intents.js";
@@ -19,6 +20,22 @@ async function findAvailableToolConnection(db: Db, task: Task, intent: Intent, p
const parsed = connectionIntentPayloadSchema.safeParse(intent.payload);
if (!parsed.success) return null;
const payload = parsed.data;
+ if (payload.savedSetup && payload.purpose === "channel") {
+ if (payload.requestingAgentId !== task.assigneeAgentId || !intent.addresseeUserId) return null;
+ const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, payload.savedSetup.endpointId),
+ eq(chatEndpoints.companyId, task.companyId), eq(chatEndpoints.connectionId, payload.savedSetup.connectionId),
+ eq(chatEndpoints.assignedAgentId, payload.requestingAgentId), eq(chatEndpoints.status, "active")));
+ if (!endpoint || endpoint.sponsorUserId !== intent.addresseeUserId) return null;
+ const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, endpoint.connectionId),
+ eq(toolConnections.companyId, task.companyId), eq(toolConnections.status, "active"), eq(toolConnections.enabled, true)));
+ if (!connection || !["ok", "healthy"].includes(connection.healthStatus)) return null;
+ const capability = await connectionManagementService(db).capability({ type: "agent", agentId: payload.requestingAgentId,
+ companyId: task.companyId, onBehalfOfUserId: intent.addresseeUserId }, "connections:configure", {
+ type: "connection_management", companyId: task.companyId, connectionId: connection.id, projectId: task.projectId,
+ issueId: task.id, targetAgentId: payload.requestingAgentId, purpose: "chat", operation: "chat.authentication_handoff",
+ });
+ return capability.allowed ? connection : null;
+ }
// Additional permissions, provider authentication and inbox setup still need
// their governed completion paths. Aggregator cards also name an upstream app.
if (payload.purpose || payload.accessRequest || payload.upstreamService
diff --git a/server/src/services/setup-token-ai-connection.ts b/server/src/services/setup-token-ai-connection.ts
index 4b3547816fa..4d7df562d3a 100644
--- a/server/src/services/setup-token-ai-connection.ts
+++ b/server/src/services/setup-token-ai-connection.ts
@@ -30,5 +30,5 @@ export async function completeSetupTokenAiConnection(
});
if (!transitioned) throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED);
},
- });
+ }, undefined, scope.managementContext);
}
diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts
index bd291a7b8cf..a1c1ec9d072 100644
--- a/server/src/services/setup-token-session.ts
+++ b/server/src/services/setup-token-session.ts
@@ -1,5 +1,6 @@
import { writeLoginCleanup } from "./login-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
+import type { ConnectionManagementContext } from "./connection-management.js";
// The Claude setup-token login session service. It owns a company-scoped,
// owner-bound login session that holds one live setup-token process and one
// sandbox lease for the whole login. The login is a two-way round-trip: the
@@ -111,6 +112,7 @@ export const SETUP_TOKEN_CANCELLABLE_STATES: readonly SetupTokenSessionState[] =
* per environment.
*/
export interface SetupTokenSessionScope {
+ managementContext?: ConnectionManagementContext;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
ownerUserId: string;
diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts
index 42645cbf3ce..68a7cba466c 100644
--- a/server/src/services/tool-access.ts
+++ b/server/src/services/tool-access.ts
@@ -1,3 +1,5 @@
+import type { ConnectionManagementContext } from "./connection-management.js";
+import { connectionActorUserId } from "./tool-access/actor.js";
import { restConnectorFor } from "./connectors/rest-registry.js";
import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js";
import { createConnectionGrants } from "./tool-access/connections/grants.js";
@@ -1421,7 +1423,7 @@ export function toolAccessService(
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})
.returning();
return toProfileBinding(created);
@@ -1594,6 +1596,7 @@ export function toolAccessService(
input: ConnectToolApp,
actor?: ActorInfo,
): Promise {
+ await actor?.managementContext?.reauthorize();
const definition = input.galleryKey
? getConnectableAppDefinition(input.galleryKey)
: null;
@@ -1880,10 +1883,10 @@ export function toolAccessService(
previousGrantKind &&
input.grantKind &&
previousGrantKind !== input.grantKind &&
- actor?.actorType === "user" &&
- actor.actorSource !== "local_implicit"
+ Boolean(connectionActorUserId(actor)) &&
+ actor?.actorSource !== "local_implicit"
) {
- const actorUserId = actor.actorId;
+ const actorUserId = connectionActorUserId(actor);
const [membership] = actorUserId
? await db
.select({
@@ -2203,15 +2206,9 @@ export function toolAccessService(
const credentialRefs: McpConnectionCredentialRef[] = [];
const createdSecretIds: string[] = [];
const createdDefinitionIds: string[] = [];
- // "Just me" needs a named board user to own the consent. An agent actor
- // cannot hold a personal identity, and silently falling back to a shared
- // credential is exactly the mis-scoping the design forbids, so refuse.
- const personalIdentityUserId =
- requestedGrantKind === "user"
- ? actor?.actorType === "user" && actor.actorId
- ? actor.actorId
- : null
- : null;
+ // Personal consent belongs to the authenticated responsible user. A delegated
+ // agent is recorded separately and never becomes the account owner.
+ const personalIdentityUserId = requestedGrantKind === "user" ? connectionActorUserId(actor) : null;
if (requestedGrantKind === "user" && !personalIdentityUserId) {
throw badRequest(
"Connecting an app as yourself requires a signed-in user",
@@ -2535,7 +2532,7 @@ export function toolAccessService(
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})
.returning();
}
@@ -3123,6 +3120,7 @@ export function toolAccessService(
input: FinishToolApp,
actor?: ActorInfo,
): Promise {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
await assertConnectorConnectionSetupEnabled(connection);
if (connection.status === "archived")
@@ -3409,7 +3407,7 @@ export function toolAccessService(
if (added.length) await tx.insert(toolConnectionInstalls).values(added.map((binding) => ({
companyId, connectionId: connection.id, targetType: binding.targetType, targetId: binding.targetId,
createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
- createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ createdByUserId: connectionActorUserId(actor),
})));
if (added.length || removed.length) await tx.insert(toolAccessAuditEvents).values({
companyId, connectionId: connection.id, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null,
@@ -3430,7 +3428,7 @@ export function toolAccessService(
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})
.returning();
profileBindings.push(toProfileBinding(binding));
@@ -3446,7 +3444,7 @@ export function toolAccessService(
reviewedByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
reviewedByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
quarantinedAt: null,
quarantineReason: null,
updatedAt: reviewedAt,
@@ -3469,7 +3467,7 @@ export function toolAccessService(
reviewedByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
reviewedByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
quarantinedAt: null,
quarantineReason: null,
updatedAt: reviewedAt,
@@ -3550,6 +3548,7 @@ export function toolAccessService(
input: { credentialValues: Record },
actor?: ActorInfo,
): Promise {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertConnectorConnectionSetupEnabled(connection);
@@ -3781,6 +3780,7 @@ export function toolAccessService(
input: CreateToolStdioCommandTemplate,
actor?: ActorInfo,
): Promise => {
+ await actor?.managementContext?.reauthorize();
if (builtInStdioTemplate(input.templateId)) {
throw conflict(
"A built-in stdio template already uses this templateId",
@@ -3807,7 +3807,7 @@ export function toolAccessService(
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})
.returning();
return toStdioCommandTemplate(row);
@@ -3857,7 +3857,9 @@ export function toolAccessService(
scopes?: string[];
returnTo?: string;
redirectUri: string;
+ managementContext?: ConnectionManagementContext;
}) => {
+ await input.managementContext?.reauthorize();
const runContext = await loadBrokerRunContext(input);
const connection = await getConnectionRow(
input.connectionId,
@@ -3879,7 +3881,7 @@ export function toolAccessService(
}
return startOAuth(input.companyId, connection.id, {
redirectUri: input.redirectUri,
- actor: { actorType: "agent", actorId: input.agentId },
+ actor: { actorType: "agent", actorId: input.agentId, ...input.managementContext?.audit, managementContext: input.managementContext },
subjectUserId: input.subjectUserId,
scopes: input.scopes,
returnTo: input.returnTo,
@@ -3943,7 +3945,7 @@ export function toolAccessService(
reviewedByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
reviewedByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
quarantinedAt: null,
quarantineReason: null,
updatedAt: reviewedAt,
@@ -4466,6 +4468,7 @@ export function toolAccessService(
input: CreateToolConnection,
actor?: ActorInfo,
): Promise => {
+ await actor?.managementContext?.reauthorize();
let applicationId = input.applicationId;
let applicationNamespace = input.applicationName ?? input.name;
const transport = input.transport;
@@ -4512,6 +4515,7 @@ export function toolAccessService(
.returning();
applicationId = app.id;
}
+ await actor?.managementContext?.reauthorize();
const connectionId = randomUUID();
const binding = actorBinding(actor);
const [row] = await db
@@ -4542,8 +4546,7 @@ export function toolAccessService(
credentialSecretRefs: input.credentialSecretRefs ?? [],
createdByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
- createdByUserId:
- binding.actorType === "user" ? binding.actorId : null,
+ createdByUserId: connectionActorUserId(actor),
})
.returning();
await ensureDefaultOrganizationGrant(row);
@@ -4647,13 +4650,14 @@ export function toolAccessService(
refreshCatalog,
configureRailwaySsh: async (connectionId: string, companyId: string, input: ConfigureRailwaySsh, actor: ActorInfo): Promise => {
+ await actor.managementContext?.reauthorize();
const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : "";
const setup = await db.transaction(async (tx) => {
const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update");
const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update");
if (!connection || !isRailwayConnection(connection) || (connection.status !== "active" && input.action !== "remove")) throw unprocessable("Connect Railway before configuring container access.");
if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization.");
- if (grant.kind === "user" && (actor.actorType !== "user" || actor.actorId !== grant.subjectUserId)) throw forbidden("Only this authorization's owner can configure its SSH key.");
+ if (grant.kind === "user" && connectionActorUserId(actor) !== grant.subjectUserId) throw forbidden("Only this authorization's owner can configure its SSH key.");
const existing = asRecord(connection.config.railwaySsh);
if (existing.grantId && existing.grantId !== grant.id) throw conflict("Remove the existing container key before choosing another authorization.");
const currentRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH);
diff --git a/server/src/services/tool-access/activity/service.ts b/server/src/services/tool-access/activity/service.ts
index 49ff10acdbb..655c17a55a3 100644
--- a/server/src/services/tool-access/activity/service.ts
+++ b/server/src/services/tool-access/activity/service.ts
@@ -1,3 +1,5 @@
+import { connectionManagementService } from "../../connection-management.js";
+import type { ConnectionManagementContext } from "../../connection-management.js";
import { and, desc, eq, gte, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, connectionTokenIssuances, authUsers, heartbeatRuns, issues, toolActionRequests, toolConnections, toolCallEvents, toolInvocations } from "@paperclipai/db";
@@ -92,10 +94,11 @@ listConnectionActivity: async (
connectionId: string,
companyId?: string,
limit = 20,
+ managementContext?: ConnectionManagementContext,
): Promise => {
const connection = await getConnectionRow(connectionId, companyId);
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
- const rows = await db
+ let rows = await db
.select()
.from(toolCallEvents)
.where(
@@ -106,6 +109,15 @@ listConnectionActivity: async (
)
.orderBy(desc(toolCallEvents.createdAt))
.limit(safeLimit);
+ if (managementContext?.actor.type === "agent") {
+ const visible: typeof rows = [];
+ for (const row of rows) {
+ if ((await connectionManagementService(db).capability(managementContext.actor, "connections:audit", {
+ ...managementContext.resource, connectionId: connection.id, issueId: row.issueId,
+ }, { mutation: false })).allowed) visible.push(row);
+ }
+ rows = visible;
+ }
const events = rows.map(toToolCallEvent);
const issueIds = [
diff --git a/server/src/services/tool-access/actor.ts b/server/src/services/tool-access/actor.ts
index 99147aaaaa1..363433779e9 100644
--- a/server/src/services/tool-access/actor.ts
+++ b/server/src/services/tool-access/actor.ts
@@ -10,3 +10,7 @@ export function actorBinding(actor: ActorInfo | undefined) {
: null,
};
}
+
+export function connectionActorUserId(actor?: ActorInfo): string | null {
+ return actor?.actorType === "user" ? actor.actorId ?? null : actor?.managementContext?.responsibleUserId ?? null;
+}
diff --git a/server/src/services/tool-access/catalog/aggregator-accounts.ts b/server/src/services/tool-access/catalog/aggregator-accounts.ts
index d83e43c25d4..6abf2ba68d2 100644
--- a/server/src/services/tool-access/catalog/aggregator-accounts.ts
+++ b/server/src/services/tool-access/catalog/aggregator-accounts.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import { actorForSecret } from "../oauth/credential-store.js";
import type { ActorInfo } from "../types.js";
import { remoteEndpoint, projectedConnectionHeaders } from "../connection-methods.js";
@@ -88,7 +89,8 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
async function openComposioGateway(connectionId: string, actor: ActorInfo) {
const connection = await getConnectionRow(connectionId);
- if (actor.actorType !== "user" || !actor.actorId) throw forbidden("A human connection manager must configure this app");
+ await actor.managementContext?.reauthorize();
+ if (!connectionActorUserId(actor)) throw forbidden("An authorized responsible user is required");
if (connection.config.sourceTemplateKey !== "composio" || connection.transport !== "mcp_remote"
|| connection.status !== "active" || !connection.enabled) throw unprocessable("Connect your Composio gateway first");
const catalog = await db.select().from(toolCatalogEntries).where(and(
@@ -225,8 +227,9 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
return provider;
}
- function assertDiscoveryActor(actor: ActorInfo) {
- if (actor.actorType !== "user" || !actor.actorId) throw forbidden("A human connection manager must check accounts");
+ async function assertDiscoveryActor(actor: ActorInfo) {
+ await actor.managementContext?.reauthorize();
+ if (!connectionActorUserId(actor)) throw forbidden("An authorized responsible user is required");
}
async function aggregatorDiscoveryState(connection: typeof toolConnections.$inferSelect, actor: ActorInfo, retryUnsupported = false): Promise {
@@ -262,7 +265,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
}
async function aggregatorAppsResponse(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise {
- assertDiscoveryActor(actor);
+ await assertDiscoveryActor(actor);
const provider = aggregatorProvider(connection);
const result = await composioAppsResponse(connection, actor);
const discovery = await aggregatorDiscoveryState(connection, actor);
@@ -322,7 +325,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
}
async function syncAggregatorApps(connectionId: string, force: boolean, actor: ActorInfo): Promise {
- assertDiscoveryActor(actor);
+ await assertDiscoveryActor(actor);
let connection = await getConnectionRow(connectionId);
const provider = aggregatorProvider(connection);
const discovery = await aggregatorDiscoveryState(connection, actor, force);
@@ -380,7 +383,8 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
}
async function configureArcadeDiscovery(connectionId: string, input: ArcadeDiscoverySetupInput, actor: ActorInfo) {
- assertDiscoveryActor(actor);
+ await actor?.managementContext?.reauthorize();
+ await assertDiscoveryActor(actor);
const companyId = (await getConnectionRow(connectionId)).companyId;
await db.transaction(async tx => {
const [connection] = await tx.select().from(toolConnections).where(eq(toolConnections.id, connectionId)).for("update");
@@ -422,6 +426,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
},
listAggregatorApps: async (connectionId: string, actor: ActorInfo) => aggregatorAppsResponse(await getConnectionRow(connectionId), actor),
refreshAggregatorApps: async (connectionId: string, requested: string[], actor: ActorInfo) => {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId);
if (aggregatorProvider(connection) === "composio" && requested.length) {
const { manage } = await openComposioGateway(connectionId, actor);
@@ -434,11 +439,13 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
syncComposioApps: startComposioAppSync,
async listComposioApps(connectionId: string, actor: ActorInfo) {
const connection = await getConnectionRow(connectionId);
- if (actor.actorType !== "user" || !actor.actorId) throw forbidden("Board access required");
+ await actor.managementContext?.reauthorize();
+ if (!connectionActorUserId(actor)) throw forbidden("An authorized responsible user is required");
if (connection.config.sourceTemplateKey !== "composio") throw notFound("Composio gateway not found");
return composioAppsResponse(connection, actor);
},
async refreshComposioApps(connectionId: string, requested: string[], actor: ActorInfo) {
+ await actor.managementContext?.reauthorize();
if (requested.some(toolkit => !findComposioCatalogApp(toolkit))) throw notFound("This Composio app is not in the catalog");
const { connection, manage } = await openComposioGateway(connectionId, actor);
const existing = await cachedComposioApps(connection, actor);
diff --git a/server/src/services/tool-access/catalog/default-access.ts b/server/src/services/tool-access/catalog/default-access.ts
index c9a459dfafc..408a33d9b14 100644
--- a/server/src/services/tool-access/catalog/default-access.ts
+++ b/server/src/services/tool-access/catalog/default-access.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import { lockManagedAppPolicies, lockToolAssignmentAgents } from "../assignment-locks.js";
import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
import { and, eq, inArray } from "drizzle-orm";
@@ -111,9 +112,7 @@ export function createCatalogDefaultAccess(db: Db) {
? (input.actor.actorId ?? null)
: null,
createdByUserId:
- input.actor?.actorType === "user"
- ? (input.actor.actorId ?? null)
- : null,
+ connectionActorUserId(input.actor),
});
} else if (resetToRecommendedDefaults) {
// Removing an app may retain its profile row when another record still
@@ -163,9 +162,7 @@ export function createCatalogDefaultAccess(db: Db) {
? (input.actor.actorId ?? null)
: null,
createdByUserId:
- input.actor?.actorType === "user"
- ? (input.actor.actorId ?? null)
- : null,
+ connectionActorUserId(input.actor),
});
}
@@ -299,9 +296,7 @@ export function createCatalogDefaultAccess(db: Db) {
? (input.actor.actorId ?? null)
: null,
createdByUserId:
- input.actor?.actorType === "user"
- ? (input.actor.actorId ?? null)
- : null,
+ connectionActorUserId(input.actor),
})
.returning();
results.push(toPolicy(created));
diff --git a/server/src/services/tool-access/catalog/health.ts b/server/src/services/tool-access/catalog/health.ts
index c008f20853e..0694f91168f 100644
--- a/server/src/services/tool-access/catalog/health.ts
+++ b/server/src/services/tool-access/catalog/health.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import { assertSupportedConnection } from "../connections/setup-policy.js";
import { connectionSetupUrl, connectionReconnectUrl } from "../identifiers.js";
import { createGitHubGrantContinuity } from "../github-grant-continuity.js";
@@ -100,16 +101,16 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
),
);
const actorGrant =
- actor?.actorType === "user"
+ connectionActorUserId(actor)
? activeGrants.find(
(grant) =>
grant.kind === "user" &&
- grant.subjectUserId === actor.actorId,
+ grant.subjectUserId === connectionActorUserId(actor),
)
: null;
const grantsToCheck =
connection.credentialPolicy === "per_user" &&
- actor?.actorType === "user"
+ Boolean(connectionActorUserId(actor))
? actorGrant
? [actorGrant]
: []
@@ -132,8 +133,8 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
connection.status === "draft" &&
connection.authKind === "none" &&
connection.credentialPolicy === "per_user" &&
- actor?.actorType === "user" &&
- actor.actorId === connection.createdByUserId;
+ Boolean(connectionActorUserId(actor)) &&
+ connectionActorUserId(actor) === connection.createdByUserId;
const credentialHeaders =
connection.credentialSource === "vercel_connect"
? await resolveCredentialHeaders(connection, actor, {
diff --git a/server/src/services/tool-access/catalog/publication.ts b/server/src/services/tool-access/catalog/publication.ts
index 113e227cbf5..84459758207 100644
--- a/server/src/services/tool-access/catalog/publication.ts
+++ b/server/src/services/tool-access/catalog/publication.ts
@@ -58,6 +58,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
credentialHeaders?: Record;
} = {},
): Promise {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId);
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog");
await assertConnectorConnectionSetupEnabled(connection);
diff --git a/server/src/services/tool-access/connections/grants.ts b/server/src/services/tool-access/connections/grants.ts
index c6b5aea2706..1836a6a52fc 100644
--- a/server/src/services/tool-access/connections/grants.ts
+++ b/server/src/services/tool-access/connections/grants.ts
@@ -1,3 +1,5 @@
+import type { ConnectionManagementContext } from "../../connection-management.js";
+import { connectionActorUserId } from "../actor.js";
import { toConnectionGrant } from "./projection.js";
import { actorBinding } from "../actor.js";
import { vercelConnectResourcesFor } from "../vercel-credentials.js";
@@ -125,6 +127,7 @@ replaceConnectionGrantMembers: async (
memberUserIds: string[],
actor?: ActorInfo,
) => {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
const [grant] = await db
.select()
@@ -278,7 +281,9 @@ replaceConnectionGrantMembers: async (
grantId: string,
agentId: string,
ownerUserId: string,
+ managementContext?: ConnectionManagementContext,
) => {
+ await managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
if (connection.connectionPurpose === "ai") {
throw badRequest("AI credentials use the connection's human access settings, not agent delegation");
@@ -363,12 +368,13 @@ replaceConnectionGrantMembers: async (
await tx.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
- actorType: "user",
- actorId: ownerUserId,
+ actorType: managementContext?.audit.actorType ?? "user",
+ actorId: managementContext?.audit.actorId ?? ownerUserId,
action: "connection_grant.delegated",
outcome: "success",
reasonCode: "delegation_created",
- details: { grantId: grant.id, delegationId: delegation!.id, agentId },
+ details: { grantId: grant.id, delegationId: delegation!.id, agentId,
+ ...(managementContext ? { responsibleUserId: managementContext.responsibleUserId, runId: managementContext.actor.runId, agentApiKeyId: managementContext.actor.keyId } : {}) },
});
return delegation!;
});
@@ -379,6 +385,7 @@ replaceConnectionGrantMembers: async (
delegationId: string,
actor?: ActorInfo,
) => {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
const delegation = await db.transaction(async tx => {
const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants)
@@ -425,6 +432,7 @@ replaceConnectionGrantMembers: async (
},
actor?: ActorInfo,
) => {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
await assertSecretRefs(
connection.companyId,
@@ -454,8 +462,7 @@ replaceConnectionGrantMembers: async (
isDefault: input.isDefault ?? false,
createdByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
- createdByUserId:
- binding.actorType === "user" ? binding.actorId : null,
+ createdByUserId: connectionActorUserId(actor),
})
.returning();
if (!grant) throw new Error("Failed to create connection installation");
@@ -480,6 +487,7 @@ replaceConnectionGrantMembers: async (
grantId: string,
actor?: ActorInfo,
) => {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
const binding = actorBinding(actor);
const [currentGrant] = await db
diff --git a/server/src/services/tool-access/connections/installs.ts b/server/src/services/tool-access/connections/installs.ts
index 2879cf8af8f..e16cda72c2c 100644
--- a/server/src/services/tool-access/connections/installs.ts
+++ b/server/src/services/tool-access/connections/installs.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import { toConnection, toConnectionInstall } from "./projection.js";
import { actorBinding } from "../actor.js";
import { lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "../assignment-locks.js";
@@ -147,6 +148,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
input: PutToolConnectionInstalls,
actor?: ActorInfo,
): Promise => {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId);
const requested = new Map(
input.installs.map((install) => [
@@ -267,7 +269,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})),
);
}
@@ -289,7 +291,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})
.onConflictDoNothing()
.returning({ id: toolProfileBindings.id });
diff --git a/server/src/services/tool-access/connections/personal-identity.ts b/server/src/services/tool-access/connections/personal-identity.ts
index 1d12a5b5137..628ad1fa957 100644
--- a/server/src/services/tool-access/connections/personal-identity.ts
+++ b/server/src/services/tool-access/connections/personal-identity.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import type { ActorInfo } from "../types.js";
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -47,7 +48,7 @@ async function fixedPersonalIdentityForReconnect(
const fixedSubjectUserId =
connection.createdByUserId ?? retainedGrant?.subjectUserId ?? null;
const binding = actorBinding(actor);
- const actorUserId = binding.actorType === "user" ? binding.actorId : null;
+ const actorUserId = connectionActorUserId(actor);
const subjectUserId = requestedSubjectUserId ?? actorUserId;
if (!subjectUserId) {
diff --git a/server/src/services/tool-access/connections/removal.ts b/server/src/services/tool-access/connections/removal.ts
index 71ab1767558..6679be5ced7 100644
--- a/server/src/services/tool-access/connections/removal.ts
+++ b/server/src/services/tool-access/connections/removal.ts
@@ -228,6 +228,7 @@ async function removeConnection(
companyId?: string,
actor?: ActorInfo,
): Promise {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack")));
if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor);
@@ -243,6 +244,7 @@ async function removeConnection(
}
async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise {
+ await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
forgetMcpHttpSessions(connection.id);
const now = new Date();
diff --git a/server/src/services/tool-access/credential-access.ts b/server/src/services/tool-access/credential-access.ts
index 63d0919ddff..65551c7aa30 100644
--- a/server/src/services/tool-access/credential-access.ts
+++ b/server/src/services/tool-access/credential-access.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "./actor.js";
import type { ActorInfo } from "./types.js";
import { oauthConfig } from "./oauth/metadata.js";
import { remoteEndpoint } from "./connection-methods.js";
@@ -39,7 +40,7 @@ export function createConnectionCredentialAccess(db: Db, { secrets, audit, verce
actor?: ActorInfo,
): Promise {
const actorUserId =
- actor?.actorType === "user" ? (actor.actorId ?? null) : null;
+ connectionActorUserId(actor);
if (actorUserId) {
const [personal] = await db
.select()
diff --git a/server/src/services/tool-access/github-grant-continuity.ts b/server/src/services/tool-access/github-grant-continuity.ts
index 31691bd7167..568a0a00a99 100644
--- a/server/src/services/tool-access/github-grant-continuity.ts
+++ b/server/src/services/tool-access/github-grant-continuity.ts
@@ -30,6 +30,7 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector
initialGrant: typeof connectionGrants.$inferSelect,
actor?: ActorInfo,
) {
+ await actor?.managementContext?.reauthorize();
return measureRunPhase("connectors.github.refresh", async () => {
try {
return await refreshManagedGitHubGrantAccessOnce(
@@ -64,6 +65,7 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector
initialGrant: typeof connectionGrants.$inferSelect,
actor?: ActorInfo,
) {
+ await actor?.managementContext?.reauthorize();
let grant = await measureRunPhase("connectors.github.credentials", () => refreshOAuthGrantCredentials({
companyId: connection.companyId,
connectionId: connection.id,
diff --git a/server/src/services/tool-access/oauth/access-finalization.ts b/server/src/services/tool-access/oauth/access-finalization.ts
index e27e8a67a0d..2b5d2265a6a 100644
--- a/server/src/services/tool-access/oauth/access-finalization.ts
+++ b/server/src/services/tool-access/oauth/access-finalization.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import type { ActorInfo } from "../types.js";
import { connectionMethodForConnection } from "../connection-methods.js";
import { emitConnectionUpdated } from "../../connector-telemetry.js";
@@ -38,6 +39,7 @@ async function finalizeOAuthAccess(
actor?: ActorInfo,
requestingAgentId?: string,
): Promise {
+ await actor?.managementContext?.reauthorize();
if (requestingAgentId)
await assertAgentsInCompany(companyId, [requestingAgentId]);
let connection = await getConnectionRow(connectionId, companyId);
@@ -49,7 +51,7 @@ async function finalizeOAuthAccess(
throw badRequest("This connection does not use browser sign-in");
if (connection.status === "archived")
throw conflict("Archived app connections cannot be finished");
- const actorUserId = actor?.actorType === "user" ? actor.actorId : null;
+ const actorUserId = connectionActorUserId(actor);
if (!actorUserId)
throw badRequest("Finishing browser sign-in requires a signed-in user");
diff --git a/server/src/services/tool-access/oauth/authorization-start.ts b/server/src/services/tool-access/oauth/authorization-start.ts
index 823b79eb5ef..58f511514b4 100644
--- a/server/src/services/tool-access/oauth/authorization-start.ts
+++ b/server/src/services/tool-access/oauth/authorization-start.ts
@@ -9,10 +9,10 @@ import { connectionMethodForConnection } from "../connection-methods.js";
import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared";
import { and, eq, lt } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
-import { agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db";
+import { companies, agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db";
import type { ConnectionGrantKind, ToolOAuthStartResult } from "@paperclipai/shared";
import { getConnectableAppDefinition } from "@paperclipai/shared";
-import { badRequest, conflict, forbidden, unprocessable } from "../../../errors.js";
+import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../errors.js";
import { isPaperclipCloudConnectorStrategy, type PaperclipCloudConnector } from "../../paperclip-cloud-connector.js";
import { deriveVercelConnectSubject, vercelConnectCallbackUrl, VercelConnectClientError, type VercelConnectClient } from "../../vercel-connect.js";
@@ -53,6 +53,7 @@ export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies
interactionId?: string;
},
): Promise {
+ await input.actor.managementContext?.reauthorize();
let connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertConnectorConnectionSetupEnabled(connection);
@@ -170,13 +171,26 @@ export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies
(!intentLink ||
intentLink.kind !== "connection_intent" ||
intentLink.status !== "pending" ||
- starterBinding.actorType !== "user" ||
- starterBinding.actorId !== intentLink.addresseeUserId)
+ (input.actor.managementContext?.responsibleUserId ?? (starterBinding.actorType === "user" ? starterBinding.actorId : null)) !== intentLink.addresseeUserId)
) {
throw forbidden(
"Only the addressed user can authorize this connection request",
);
}
+ if (input.actor.managementContext?.actor.type === "agent") {
+ // An agent has no browser session. Reuse the saved setup; the addressed
+ // human starts provider authorization in their own authenticated session.
+ // No provider state or credentials are issued by this handoff.
+ const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId));
+ if (!company) throw notFound("Company not found");
+ const destination = new URL(input.redirectUri);
+ destination.pathname = `/${encodeURIComponent(company.prefix)}/apps/${connection.credentialSource === "vercel_connect" ? "vercel-connect" : "connect"}`;
+ destination.search = new URLSearchParams({ [connection.status === "draft" ? "resume" : "reconnect"]: connection.id,
+ ...(sourceTemplateKey ? { source: sourceTemplateKey } : {}), ...(intentLink ? { intent: intentLink.id } : {}) }).toString();
+ destination.hash = "";
+ return { connectionId: connection.id, provider: sourceTemplateKey ?? "oauth",
+ authorizationUrl: destination.toString(), expiresAt: null };
+ }
if (connection.credentialSource === "vercel_connect") {
if (!vercelConnect)
throw vercelConnectHttpError(
diff --git a/server/src/services/tool-access/oauth/catalog-completion.ts b/server/src/services/tool-access/oauth/catalog-completion.ts
index 95d293cd1b2..b655c764915 100644
--- a/server/src/services/tool-access/oauth/catalog-completion.ts
+++ b/server/src/services/tool-access/oauth/catalog-completion.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import type { ActorInfo } from "../types.js";
import { isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { and, eq } from "drizzle-orm";
@@ -138,9 +139,7 @@ export function createOAuthCatalogCompletion(db: Db, finishGalleryAppConnection:
? (input.actor.actorId ?? null)
: null,
createdByUserId:
- input.actor?.actorType === "user"
- ? (input.actor.actorId ?? null)
- : null,
+ connectionActorUserId(input.actor),
})),
)
.onConflictDoNothing();
diff --git a/server/src/services/tool-access/oauth/connection-refresh.ts b/server/src/services/tool-access/oauth/connection-refresh.ts
index 959d0619c00..f6127cb9581 100644
--- a/server/src/services/tool-access/oauth/connection-refresh.ts
+++ b/server/src/services/tool-access/oauth/connection-refresh.ts
@@ -366,6 +366,7 @@ const oauthRefreshFlights = new Map>();
heartbeatRunId?: string | null;
},
): Promise {
+ await actor?.managementContext?.reauthorize();
const oauth = oauthConfig(connection);
if (
typeof oauth.tokenUrl !== "string" ||
diff --git a/server/src/services/tool-access/oauth/credential-store.ts b/server/src/services/tool-access/oauth/credential-store.ts
index 9e786c891f9..a4d59570235 100644
--- a/server/src/services/tool-access/oauth/credential-store.ts
+++ b/server/src/services/tool-access/oauth/credential-store.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import type { ActorInfo, DbTransaction, ToolAccessMutationDb } from "../types.js";
import { oauthConfig } from "./metadata.js";
import { syncConnectionCredentialBindings } from "../../connection-credential-bindings.js";
@@ -80,8 +81,7 @@ async function syncCredentialBindings(
let credentialSecretRefs = connection.credentialSecretRefs;
if (
connection.credentialPolicy === "per_user" &&
- actor?.actorType === "user" &&
- actor.actorId &&
+ Boolean(connectionActorUserId(actor)) &&
!credentialSecretRefs.some(
(ref) => ref.configPath === "oauth.client_secret",
)
@@ -96,7 +96,7 @@ async function syncCredentialBindings(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
- eq(connectionGrants.subjectUserId, actor.actorId),
+ eq(connectionGrants.subjectUserId, connectionActorUserId(actor)!),
eq(connectionGrants.status, "active"),
),
)
diff --git a/server/src/services/tool-access/oauth/vercel-callback.ts b/server/src/services/tool-access/oauth/vercel-callback.ts
index 87164abd425..452497f38ea 100644
--- a/server/src/services/tool-access/oauth/vercel-callback.ts
+++ b/server/src/services/tool-access/oauth/vercel-callback.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "../actor.js";
import { vercelConnectHttpError, vercelCredentialFor, vercelConnectResourcesFor } from "../vercel-credentials.js";
import type { ActorInfo } from "../types.js";
import { connectionMethodForConnection, projectedConnectionHeaders } from "../connection-methods.js";
@@ -174,9 +175,7 @@ export function createVercelOAuthCallback(db: Db, dependencies: Dependencies) {
subjectUserId: stateRow.subjectUserId,
...grantValues,
createdByUserId:
- input.actor?.actorType === "user"
- ? (input.actor.actorId ?? null)
- : null,
+ connectionActorUserId(input.actor),
});
}
const preActivationLifecycle = {
diff --git a/server/src/services/tool-access/profiles/service.ts b/server/src/services/tool-access/profiles/service.ts
index ed24716410c..5a208089025 100644
--- a/server/src/services/tool-access/profiles/service.ts
+++ b/server/src/services/tool-access/profiles/service.ts
@@ -1,3 +1,5 @@
+import type { ConnectionManagementContext } from "../../connection-management.js";
+import { connectionActorUserId } from "../actor.js";
import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js";
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
@@ -156,6 +158,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
input: ReviewToolProfileNewTools,
actor?: ActorInfo,
): Promise {
+ await actor?.managementContext?.reauthorize();
const profile = await getProfileRow(profileId);
const review = await listProfileNewTools(profile.id, profile.companyId);
if (review.tools.length === 0)
@@ -213,7 +216,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
reviewedByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
reviewedByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
updatedAt: nowAt,
})
.where(
@@ -460,8 +463,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
createProfile: async (
companyId: string,
- input: CreateToolProfileWithEntries,
+ input: CreateToolProfileWithEntries, managementContext?: ConnectionManagementContext,
): Promise => {
+ await managementContext?.reauthorize();
await assertProfileEntryInputs(db, companyId, input.entries ?? []);
const [row] = await db
.insert(toolProfiles)
@@ -487,8 +491,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
updateProfile: async (
profileId: string,
- input: UpdateToolProfileWithEntries,
+ input: UpdateToolProfileWithEntries, managementContext?: ConnectionManagementContext,
): Promise => {
+ await managementContext?.reauthorize();
const existing = await getProfileRow(profileId);
if (input.entries) {
await assertProfileEntryInputs(db, existing.companyId, input.entries);
@@ -538,8 +543,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
duplicateProfile: async (
profileId: string,
- input: DuplicateToolProfile,
+ input: DuplicateToolProfile, managementContext?: ConnectionManagementContext,
): Promise => {
+ await managementContext?.reauthorize();
const existing = await getProfileRow(profileId);
const [entries, bindings] = await Promise.all([
db
@@ -614,13 +620,14 @@ export function createToolProfileService(db: Db, now: () => Date) {
deleteProfile: async (
profileId: string,
- input: DeleteToolProfile,
+ input: DeleteToolProfile, managementContext?: ConnectionManagementContext,
): Promise<{
profile: ToolProfile;
summary: ToolProfileSummary;
reassignedToProfileId: string | null;
reassignedBindingCount: number;
}> => db.transaction(async tx => {
+ await managementContext?.reauthorize();
const existing = await getProfileRow(profileId, undefined, tx);
const db = tx as unknown as Db;
await tx.select({ id: toolProfiles.id }).from(toolProfiles)
@@ -715,8 +722,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
addProfileEntry: async (
profileId: string,
- input: CreateToolProfileEntryForProfile,
+ input: CreateToolProfileEntryForProfile, managementContext?: ConnectionManagementContext,
): Promise => db.transaction(async tx => {
+ await managementContext?.reauthorize();
const db = tx as unknown as Db;
const profile = await getProfileRow(profileId, undefined, tx);
await assertProfileEntryInputs(tx, profile.companyId, [input]);
@@ -755,8 +763,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
updateProfileEntry: async (
entryId: string,
- input: UpdateToolProfileEntry,
+ input: UpdateToolProfileEntry, managementContext?: ConnectionManagementContext,
): Promise => db.transaction(async tx => {
+ await managementContext?.reauthorize();
const db = tx as unknown as Db;
const [existing] = await db
.select()
@@ -798,7 +807,8 @@ export function createToolProfileService(db: Db, now: () => Date) {
return toProfileEntry(row);
}),
- deleteProfileEntry: async (entryId: string): Promise => db.transaction(async tx => {
+ deleteProfileEntry: async (entryId: string, managementContext?: ConnectionManagementContext): Promise => db.transaction(async tx => {
+ await managementContext?.reauthorize();
const db = tx as unknown as Db;
const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId));
if (!existing) throw notFound("Tool profile entry not found");
@@ -820,6 +830,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
input: CreateToolProfileBindingForProfile,
actor?: ActorInfo,
): Promise => db.transaction(async tx => {
+ await actor?.managementContext?.reauthorize();
const db = tx as unknown as Db;
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!profile) throw notFound("Tool profile not found");
@@ -841,7 +852,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
- actor?.actorType === "user" ? (actor.actorId ?? null) : null,
+ connectionActorUserId(actor),
})
.returning();
await db
@@ -853,8 +864,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
unbindProfile: async (
profileId: string,
- input: UnbindToolProfileBinding,
+ input: UnbindToolProfileBinding, managementContext?: ConnectionManagementContext,
): Promise<{ unbound: number }> => db.transaction(async tx => {
+ await managementContext?.reauthorize();
const db = tx as unknown as Db;
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!profile) throw notFound("Tool profile not found");
diff --git a/server/src/services/tool-access/runtime/slots.ts b/server/src/services/tool-access/runtime/slots.ts
index 3ca8e6b4d81..fab56ccece9 100644
--- a/server/src/services/tool-access/runtime/slots.ts
+++ b/server/src/services/tool-access/runtime/slots.ts
@@ -114,6 +114,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick {
+ await input.actor?.managementContext?.reauthorize();
try {
if (input.action === "stop") {
await runtimeSupervisor.stopSlot({
@@ -132,6 +133,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick[0]>[0];
export type ToolAccessMutationDb = Pick<
diff --git a/server/src/services/tool-access/vercel-credentials.ts b/server/src/services/tool-access/vercel-credentials.ts
index 10bb87628dc..643a19e4113 100644
--- a/server/src/services/tool-access/vercel-credentials.ts
+++ b/server/src/services/tool-access/vercel-credentials.ts
@@ -1,3 +1,4 @@
+import { connectionActorUserId } from "./actor.js";
import type { ActorInfo } from "./types.js";
import { remoteEndpoint } from "./connection-methods.js";
import { and, eq } from "drizzle-orm";
@@ -140,7 +141,7 @@ export function createVercelCredentialService(db: Db, now: () => Date, vercelCon
actor?: ActorInfo,
) {
const actorUserId =
- actor?.actorType === "user" ? (actor.actorId ?? null) : null;
+ connectionActorUserId(actor);
if (actorUserId) {
const [personal] = await db
.select()
diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts
index f5971156148..c0ba74cf7fd 100644
--- a/server/src/services/tool-gateway.ts
+++ b/server/src/services/tool-gateway.ts
@@ -4,6 +4,7 @@ import { promptInjectionPreventionService } from "./prompt-injection-prevention.
import { adapterExecutionControls } from "./adapter-execution-control.js";
import type { BudgetServiceHooks } from "./budgets.js";
import type { PromptInjectionDetectorMetadata } from "@paperclipai/shared";
+import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js";
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js";
import { nativeProviderCatalogFor } from "./native-provider-catalogs.js";
@@ -404,6 +405,7 @@ interface ExecuteGatewayToolInput {
interface ExecuteTestCallInput {
signal?: AbortSignal;
+ managementContext?: ConnectionManagementContext;
companyId: string;
connectionId: string;
agentId: string;
@@ -6792,6 +6794,7 @@ export function createToolGatewayService(
revokedAt: row.revokedAt,
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
+ responsibleUserId: row.responsibleUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -7337,6 +7340,29 @@ export function createToolGatewayService(
});
}
}
+ if (row.token.subjectType === "gateway_client" && (row.token.createdByAgentId || row.token.responsibleUserId)) {
+ // Durable clients inherit current issuer/user authority, not the setup run's lifetime.
+ // The issuer FK is cleared on agent deletion. Retain the responsible-user
+ // marker so deletion cannot turn delegated credentials into system clients.
+ if (!row.token.createdByAgentId || !row.token.responsibleUserId) return recordNamedGatewayAuthFailure({
+ gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId, bearerToken,
+ reasonCode: "gateway_token_run_context_invalid", clientMetadata,
+ });
+ const delegatedActor = { type: "agent" as const, companyId: row.gateway.companyId,
+ agentId: row.token.createdByAgentId, onBehalfOfUserId: row.token.responsibleUserId };
+ const resource = { type: "connection_management" as const, companyId: row.gateway.companyId,
+ gatewayId: row.gateway.id, profileId: row.gateway.profileId, purpose: "tool" as const, operation: "gateway_token.use", targetAgentId: row.gateway.agentId,
+ projectId: row.gateway.projectId, issueId: row.gateway.issueId };
+ try {
+ await connectionManagementService(db).authorize(delegatedActor, "connections:gateway", resource, { mutation: false });
+ await connectionManagementService(db).authorize(delegatedActor, "connections:test", resource, { mutation: false });
+ } catch {
+ return recordNamedGatewayAuthFailure({ gatewayId: input.gatewayId, gatewayPublicId: input.gatewayPublicId,
+ bearerToken, reasonCode: "gateway_token_run_context_invalid", clientMetadata });
+ }
+ agentId ??= row.token.createdByAgentId;
+ responsibleUserId = row.token.responsibleUserId;
+ }
const now = new Date();
await db
.update(toolMcpGatewayTokens)
@@ -7358,8 +7384,8 @@ export function createToolGatewayService(
gatewayTokenAllowedActions: normalizeGatewayTokenActions(
row.token.allowedActions,
),
- actorType: runId ? "agent" : "system",
- actorId: runId ? agentId : row.token.id,
+ actorType: runId || responsibleUserId ? "agent" : "system",
+ actorId: runId ? agentId : responsibleUserId ? row.token.createdByAgentId : row.token.id,
responsibleUserId,
createdAt: row.token.createdAt,
expiresAt:
@@ -7386,7 +7412,7 @@ export function createToolGatewayService(
invocation: typeof toolInvocations.$inferSelect,
): boolean {
return (
- invocation.actorType === "user" &&
+ (invocation.actorType === "user" || Boolean(invocation.policyExplanation?.delegatedConnectionTest)) &&
invocation.runId === null &&
invocation.issueId === null &&
invocation.gatewayId === null &&
@@ -7436,8 +7462,8 @@ export function createToolGatewayService(
agentId: args.agentId,
runId: null,
issueId: null,
- actorType: "user",
- actorId: args.userId,
+ actorType: args.session.actorType,
+ actorId: args.session.actorId ?? args.userId,
action: "tool_gateway.call_allowed",
details: {
source: "test",
@@ -7531,8 +7557,8 @@ export function createToolGatewayService(
agentId: args.agentId,
runId: null,
issueId: null,
- actorType: "user",
- actorId: args.userId,
+ actorType: args.session.actorType,
+ actorId: args.session.actorId ?? args.userId,
action: "tool_gateway.call_completed",
details: {
source: "test",
@@ -7610,8 +7636,8 @@ export function createToolGatewayService(
agentId: args.agentId,
runId: null,
issueId: null,
- actorType: "user",
- actorId: args.userId,
+ actorType: args.session.actorType,
+ actorId: args.session.actorId ?? args.userId,
action:
status === 504
? "tool_gateway.call_deferred"
@@ -7658,7 +7684,11 @@ export function createToolGatewayService(
): Promise {
const agentId = invocation.agentId;
if (!invocation.connectionId || !agentId) return;
- const userId = invocation.actorId ?? "board";
+ const delegated = invocation.policyExplanation?.delegatedConnectionTest as {
+ agentId: string; responsibleUserId: string; runId: string | null; keyId: string | null;
+ resource: ConnectionManagementContext["resource"];
+ } | undefined;
+ const userId = delegated?.responsibleUserId ?? invocation.actorId ?? "board";
const session: ToolGatewaySession = {
id: "test-call",
token: "test-call",
@@ -7667,8 +7697,8 @@ export function createToolGatewayService(
runId: null,
issueId: null,
projectId: null,
- actorType: "user",
- actorId: userId,
+ actorType: delegated ? "agent" : "user",
+ actorId: delegated?.agentId ?? userId,
responsibleUserId: userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
@@ -7715,6 +7745,16 @@ export function createToolGatewayService(
promptInjectionMode: "ignore",
})).summary;
try {
+ if (delegated) {
+ await connectionManagementService(db).authorize({ type: "agent", companyId: invocation.companyId,
+ agentId: delegated.agentId, onBehalfOfUserId: delegated.responsibleUserId,
+ runId: delegated.runId, keyId: delegated.keyId,
+ }, "connections:test", delegated.resource, { targetAgentIds: [agentId] });
+ const current = await policyService.decide(policyInputForAgentTool({
+ companyId: invocation.companyId, agentId, actorType: "agent", actorId: delegated.agentId, tool, parameters,
+ }));
+ if (!current.allowed && current.decision !== "require_approval") throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode);
+ }
await runTestToolInvocation({
session,
tool,
@@ -8772,7 +8812,9 @@ export function createToolGatewayService(
companyId: string;
body: CreateToolMcpGateway;
actor?: { agentId?: string | null; userId?: string | null };
+ managementContext?: ConnectionManagementContext;
}): Promise {
+ await input.managementContext?.reauthorize();
await assertGatewayContext({
companyId: input.companyId,
profileId: input.body.profileId,
@@ -8872,6 +8914,9 @@ export function createToolGatewayService(
profileId: gateway.profileId,
},
});
+ if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
+ action: "tool_gateway.created", entityType: "tool_mcp_gateway", entityId: gateway.id,
+ details: { profileId: gateway.profileId } });
return getGatewayWithTokens(input.companyId, gateway.id);
},
@@ -8879,7 +8924,9 @@ export function createToolGatewayService(
companyId: string;
gatewayId: string;
body: UpdateToolMcpGateway;
+ managementContext?: ConnectionManagementContext;
}): Promise {
+ await input.managementContext?.reauthorize();
const [existing] = await db
.select()
.from(toolMcpGateways)
@@ -8991,6 +9038,9 @@ export function createToolGatewayService(
})
.onConflictDoNothing();
}
+ if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
+ action: "tool_gateway.updated", entityType: "tool_mcp_gateway", entityId: updated.id,
+ details: { profileId: updated.profileId, status: updated.status } });
return getGatewayWithTokens(input.companyId, updated.id);
},
@@ -8999,7 +9049,9 @@ export function createToolGatewayService(
gatewayId: string;
body: CreateToolMcpGatewayToken;
actor?: { agentId?: string | null; userId?: string | null };
+ managementContext?: ConnectionManagementContext;
}): Promise {
+ await input.managementContext?.reauthorize();
const [gateway] = await db
.select()
.from(toolMcpGateways)
@@ -9016,6 +9068,13 @@ export function createToolGatewayService(
"MCP gateway not found",
"gateway_not_found",
);
+ if (input.actor?.agentId && !input.managementContext && input.body.subjectType !== "heartbeat_run") {
+ throw new ToolGatewayHttpError(403, "Agent client tokens require delegated authority", "gateway_token_run_context_invalid");
+ }
+ if (input.managementContext?.actor.type === "agent" && input.body.subjectType === "heartbeat_run" &&
+ (!input.managementContext.actor.runId || input.body.subjectId !== input.managementContext.actor.runId)) {
+ throw new ToolGatewayHttpError(403, "Heartbeat tokens must bind the authenticated run", "gateway_token_run_context_invalid");
+ }
const tokenId = randomUUID();
const token = generateNamedGatewayToken(tokenId);
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
@@ -9052,12 +9111,16 @@ export function createToolGatewayService(
? input.actor.userId
: null,
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
+ responsibleUserId: input.managementContext?.actor.type === "agent" ? input.managementContext.responsibleUserId : null,
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
+ if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
+ action: "tool_gateway.token_created", entityType: "tool_mcp_gateway", entityId: input.gatewayId,
+ details: { tokenId: row.id, subjectType: row.subjectType, allowedActions: row.allowedActions } });
return { ...toGatewayToken(row), token };
},
@@ -9065,7 +9128,9 @@ export function createToolGatewayService(
companyId: string;
tokenId: string;
revokedAt?: Date;
+ managementContext?: ConnectionManagementContext;
}): Promise {
+ await input.managementContext?.reauthorize();
const now = input.revokedAt ?? new Date();
const [row] = await db
.update(toolMcpGatewayTokens)
@@ -9083,6 +9148,9 @@ export function createToolGatewayService(
"MCP gateway token not found",
"gateway_token_not_found",
);
+ if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
+ action: "tool_gateway.token_revoked", entityType: "tool_mcp_gateway", entityId: row.gatewayId,
+ details: { tokenId: row.id } });
return toGatewayToken(row);
},
@@ -9421,6 +9489,8 @@ export function createToolGatewayService(
},
async executeTestCall(input: ExecuteTestCallInput) {
+ await input.managementContext?.reauthorize();
+ const actingAgentId = input.managementContext?.actor.type === "agent" ? input.managementContext.actor.agentId : null;
await assertAgentInCompany(input.companyId, input.agentId);
const session: ToolGatewaySession = {
id: "test-call",
@@ -9430,8 +9500,8 @@ export function createToolGatewayService(
runId: null,
issueId: null,
projectId: null,
- actorType: "user",
- actorId: input.userId,
+ actorType: actingAgentId ? "agent" : "user",
+ actorId: actingAgentId ?? input.userId,
responsibleUserId: input.userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
@@ -9472,8 +9542,8 @@ export function createToolGatewayService(
const decisionInput = policyInputForAgentTool({
companyId: input.companyId,
agentId: input.agentId,
- actorType: "user",
- actorId: input.userId,
+ actorType: actingAgentId ? "agent" : "user",
+ actorId: actingAgentId ?? input.userId,
tool,
parameters: requestedParameters,
idempotencyKey: `test-call:${randomUUID()}`,
@@ -9486,6 +9556,16 @@ export function createToolGatewayService(
);
await policyService.writeAudit(decisionInput, accessDecision);
const invocationId = recorded.invocation.id;
+ if (actingAgentId && input.managementContext) {
+ await db.update(toolInvocations).set({ policyExplanation: {
+ ...recorded.invocation.policyExplanation,
+ delegatedConnectionTest: {
+ agentId: actingAgentId, responsibleUserId: input.managementContext.responsibleUserId,
+ runId: input.managementContext.actor.runId ?? null, keyId: input.managementContext.actor.keyId ?? null,
+ resource: input.managementContext.resource,
+ },
+ } }).where(eq(toolInvocations.id, invocationId));
+ }
if (accessDecision.decision === "require_approval") {
if (!recorded.actionRequest) {
@@ -9593,8 +9673,8 @@ export function createToolGatewayService(
agentId: input.agentId,
runId: null,
issueId: null,
- actorType: "user",
- actorId: input.userId,
+ actorType: actingAgentId ? "agent" : "user",
+ actorId: actingAgentId ?? input.userId,
action: "tool_gateway.approval_requested",
details: {
source: "test",
@@ -9625,8 +9705,8 @@ export function createToolGatewayService(
agentId: input.agentId,
runId: null,
issueId: null,
- actorType: "user",
- actorId: input.userId,
+ actorType: actingAgentId ? "agent" : "user",
+ actorId: actingAgentId ?? input.userId,
action: "tool_gateway.call_denied",
details: {
source: "test",
@@ -9650,6 +9730,7 @@ export function createToolGatewayService(
};
}
+ await input.managementContext?.reauthorize();
return runTestToolInvocation({
session,
tool,
@@ -11626,14 +11707,19 @@ export function createToolGatewayService(
companyId: string;
slotId: string;
actor?: { agentId?: string | null; runId?: string | null };
+ managementContext?: ConnectionManagementContext;
}) {
+ await input.managementContext?.reauthorize();
try {
- return await runtimeSupervisor.stopSlot({
+ const result = await runtimeSupervisor.stopSlot({
companyId: input.companyId,
slotId: input.slotId,
agentId: input.actor?.agentId ?? null,
runId: input.actor?.runId ?? null,
});
+ if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
+ action: "tool_runtime.stopped", entityType: "tool_runtime_slot", entityId: input.slotId });
+ return result;
} catch (err) {
if (err instanceof ToolRuntimeSupervisorError) {
throw new ToolGatewayHttpError(
@@ -11651,14 +11737,19 @@ export function createToolGatewayService(
companyId: string;
slotId: string;
actor?: { agentId?: string | null; runId?: string | null };
+ managementContext?: ConnectionManagementContext;
}) {
+ await input.managementContext?.reauthorize();
try {
- return await runtimeSupervisor.restartSlot({
+ const result = await runtimeSupervisor.restartSlot({
companyId: input.companyId,
slotId: input.slotId,
agentId: input.actor?.agentId ?? null,
runId: input.actor?.runId ?? null,
});
+ if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
+ action: "tool_runtime.restarted", entityType: "tool_runtime_slot", entityId: input.slotId });
+ return result;
} catch (err) {
if (err instanceof ToolRuntimeSupervisorError) {
throw new ToolGatewayHttpError(
diff --git a/ui/src/api/ai-connections.ts b/ui/src/api/ai-connections.ts
index cfe31188368..419e60ed011 100644
--- a/ui/src/api/ai-connections.ts
+++ b/ui/src/api/ai-connections.ts
@@ -1,6 +1,7 @@
import type { AiConnectionList, AiConnectionUsage, CreateAiConnection, AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared";
import { api } from "./client";
export const aiConnectionsApi = {
+ loginSetup: (companyId: string, sessionId: string) => api.get<{ sessionId: string; intent: AiConnectionLoginIntent; environmentId: string; status: string; connectionId: string | null; grantId: string | null }>(`/companies/${companyId}/ai-connections/login/${encodeURIComponent(sessionId)}/setup`),
probeUsage: (companyId: string, connectionId: string, grantId?: string) => api.get(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`),
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post(`/companies/${companyId}/ai-connections/local/attempts`, input),
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post(`/companies/${companyId}/ai-connections/local/check`, input),
diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
index f9ac280ec45..d6dbe090427 100644
--- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
+++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx
@@ -237,6 +237,20 @@ afterEach(async () => {
});
describe("ConnectionIntentInteractionBody states and audience", () => {
+ it("opens the saved chat authentication step without another setup form", async () => {
+ const interaction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload,
+ purpose: "channel" as const, serviceSlug: "slack", serviceName: "Slack", phase: "authorizing" as const,
+ savedSetup: { endpointId: "endpoint", connectionId: "connection" } } };
+ setupOptionsMock.mockResolvedValue({ version: 1, interaction, requestedAgentId: interaction.payload.requestingAgentId,
+ existingConnections: [], service: { service: "slack", name: "Slack", methods: [], state: "needs_user_action" },
+ chatSetup: { endpointId: "endpoint", connectionId: "connection", path: "/ACME/apps/chat/connect?provider=slack&resume=endpoint" } });
+ renderBody(interaction);
+ await flush();
+ expect(document.querySelector('a')?.getAttribute("href")).toBe("/ACME/apps/chat/connect?provider=slack&resume=endpoint");
+ expect(document.querySelector('[data-testid="shared-connection-setup"]')).toBeNull();
+ expect(completeMock).not.toHaveBeenCalled();
+ });
+
it.each([
[pendingConnectionIntentInteraction, "Connect"],
[
diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx
index a87af21f786..e0d22dc6621 100644
--- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx
+++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx
@@ -64,6 +64,7 @@ export function ConnectionIntentInteractionBody({
);
const isPending = interaction.status === "pending";
const isAi = interaction.payload.purpose === "ai";
+ const isChatHandoff = Boolean(interaction.payload.savedSetup);
const provider = isAi ? aiProviderSchema.safeParse(interaction.payload.serviceSlug).data : undefined;
const serviceName = provider ? AI_CONNECTION_CAPABILITIES[provider].name : interaction.payload.serviceName;
const isEmail = interaction.payload.purpose === "channel" && interaction.payload.serviceSlug === "agentmail";
@@ -472,6 +473,7 @@ export function ConnectionIntentInteractionBody({
? `${interaction.payload.requestingAgentName} needs your own AI connection. Connect here and the task will resume automatically.`
: interaction.payload.purpose === "ai"
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
+ : isChatHandoff ? "Complete the human sign-in step in the saved setup. Your task continues when the connection is ready."
: isEmail ? "Connect AgentMail to create an email address for this agent."
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
@@ -486,7 +488,10 @@ export function ConnectionIntentInteractionBody({
) : null}
- {isEmail ? setupQuery.isLoading || setupQuery.isError ? <>
+ {isChatHandoff ? : isEmail ? setupQuery.isLoading || setupQuery.isError ? <>
{setupContent}
> : {
expect(startOAuthMock).not.toHaveBeenCalled();
});
+ it("opens the saved AI sign-in and reuses a completed account", async () => {
+ mockSearch.value = "aiLogin=saved-login";
+ const setup = vi.spyOn(aiConnectionsApi, "loginSetup").mockResolvedValue({
+ sessionId: "saved-login", environmentId: "local-env", status: "completed", connectionId: "saved-account", grantId: "saved-grant",
+ intent: { provider: "openai", method: "subscription", name: "Saved account", ownership: "personal", agentIds: ["agent-1"], allAgents: false },
+ });
+ await render();
+ expect(setup).toHaveBeenCalledWith("company-1", "saved-login");
+ expect(container.textContent).toContain("This account is connected.");
+ expect(connectAppMock).not.toHaveBeenCalled();
+ await act(async () => buttonByText("View connection")?.click());
+ expect(mockNavigate).toHaveBeenCalledWith("/apps/saved-account/permissions");
+ });
+
+ it("shows an unavailable saved sign-in without creating a replacement", async () => {
+ mockSearch.value = "aiLogin=another-users-login";
+ vi.spyOn(aiConnectionsApi, "loginSetup").mockRejectedValue(new Error("Sign-in attempt not found"));
+ await render();
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe("Sign-in attempt not found");
+ expect(connectAppMock).not.toHaveBeenCalled();
+ });
+
it("keeps Honcho setup blocked until the provider workspace is supplied", async () => {
const honcho = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "honcho")!;
experimentalMock.mockResolvedValue({ enableMemoryConnectors: true });
diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx
index afa5e5ba887..55e95c99ae4 100644
--- a/ui/src/pages/apps/AppsConnect.tsx
+++ b/ui/src/pages/apps/AppsConnect.tsx
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { aiConnectionsApi } from "@/api/ai-connections";
+import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
import { AiConnectionPoolConnector } from "@/components/ai-connections/AiConnectionPoolConnector";
import { useEffect } from "react";
@@ -27,6 +28,9 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
const { appKey } = useParams<{ appKey?: string }>();
const source = searchParams.get("source") ?? appKey ?? searchParams.get("appKey");
const reconnectId = searchParams.get("reconnect");
+ const aiLoginId = searchParams.get("aiLogin");
+ const login = useQuery({ queryKey: ["ai-login-setup", selectedCompanyId, aiLoginId],
+ queryFn: () => aiConnectionsApi.loginSetup(selectedCompanyId!, aiLoginId!), enabled: Boolean(selectedCompanyId && aiLoginId), retry: false });
const preset = aiProviderSetupPreset(source);
const aiReconnectRequested = Boolean(reconnectId && (preset || ["openai", "anthropic", "xai"].includes(source ?? "")));
const aiAccounts = useQuery({ queryKey: ["ai-connections", selectedCompanyId], queryFn: () => aiConnectionsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId && aiReconnectRequested) });
@@ -55,6 +59,16 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau
const path = selectedCompanyId && consumeSkillSourceReturn(selectedCompanyId);
if (path) navigate(path);
}
+ if (aiLoginId && selectedCompanyId) {
+ if (login.isPending) return Loading saved sign-in…
;
+ if (!login.data) return {login.error?.message ?? "This sign-in is unavailable."}
;
+ const { intent, environmentId, connectionId, grantId } = login.data;
+ if (connectionId && grantId) return This account is connected.
;
+ return navigate("/apps")}
+ onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)} />;
+ }
if (aiReconnectRequested && aiAccounts.isPending) return Loading connection…
;
if (aiReconnectRequested && aiAccounts.isError) return Could not load this connection. Refresh to try again.
;
if (aiReconnectRequested && !aiReconnect) return This connection is unavailable. Return to Connectors to choose an account.
;