feat(connections): let agents configure connections within delegated authority

Keep management bounded by responsible users, scoped grants, current trust, and existing execution policy. Preserve human authentication handoffs and delegated gateway token authority.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 08:55:24 -05:00
1 parent a1f77821b9
commit dfc9d612eb
99 files changed
+3118 -947

No files matched your search

+8
View File
@@ -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.
+8
View File
@@ -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.
+8
View File
@@ -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.
+8
View File
@@ -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.
@@ -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.
@@ -0,0 +1 @@
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
@@ -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",
+7
View File
@@ -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
}
]
}
+2
View File
@@ -75,6 +75,8 @@ export const chatGitHubRegistrations = pgTable(
manifestClaimId?: string;
identityLeaseId?: string;
identityLeaseExpiresAt?: string;
browserSessionId?: string;
identitySessionId?: string;
identityStateHash?: string;
identityExpiresAt?: string;
identityRedemptionId?: string;
+2
View File
@@ -564,6 +564,8 @@ export const toolMcpGatewayTokens = pgTable(
subjectType: text("subject_type").$type<ToolMcpGatewayTokenSubjectType>().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<ToolMcpGatewayTokenAction[]>().notNull().default(["tools/list", "tools/call"]),
expiresAt: timestamp("expires_at", { withTimezone: true }),
@@ -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`.";
@@ -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", () => {
@@ -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.";
+1
View File
@@ -70,6 +70,7 @@ export type {
ConnectionSearchResultItem,
ConnectionsSearchResult,
ConnectionRequestResult,
ConnectionRequiredUserAction,
ConnectionIntentSetupOptions,
ConnectionIntentSetupConnection,
CompleteConnectionIntentInput,
@@ -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<ToolConnection, "id" | "applicationId" | "name" | "status" | "enabled">;
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;
+1
View File
@@ -12,6 +12,7 @@ export type {
ConnectionSearchResultItem,
ConnectionsSearchResult,
ConnectionRequestResult,
ConnectionRequiredUserAction,
ConnectionIntentSetupOptions,
ConnectionIntentSetupConnection,
CompleteConnectionIntentInput,
+2
View File
@@ -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?: {
+6 -1
View File
@@ -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.
+1
View File
@@ -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),
@@ -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());
@@ -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");
@@ -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",
);
}
@@ -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<ReturnType<typeof startRunnerApiTestServer>>;
let lifecycle: ReturnType<typeof configureAgentLifecycle>;
let provider: ((url: string, init?: RequestInit) => Promise<Response>) | null = null;
let providerFetch: ReturnType<typeof vi.spyOn>;
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<string, { operationId: string }> }).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 });
});
});
@@ -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" });
@@ -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();
});
@@ -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<unknown>) {
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;
}
@@ -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<string, string>();
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<string, unknown>; 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
+18 -11
View File
@@ -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();
@@ -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();
@@ -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))
+5 -5
View File
@@ -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 () => {
+3 -3
View File
@@ -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 =
+46 -20
View File
@@ -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<string> = 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<string, { id: string; label: string }>();
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<string, unknown>;
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;
+81 -67
View File
@@ -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<boolean> {
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<boolean> {
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<typeof supportsLo
if (!supportsLocalAiLogin(options)) throw unprocessable("Server-host subscription sign-in is unavailable on this hosted instance. Choose a supported sign-in environment or use an API key.");
}
const router = Router();
const managementGuard = connectionManagementGuard(db, "ai");
const service = aiConnectionService(db);
const localLogin = localAiLoginService(db);
const pools = aiConnectionRouterService(db);
router.get("/companies/:companyId/ai-connection-pools", async (req, res) => {
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<typeof supportsLo
res.setHeader("Cache-Control", "no-store");
res.json(await localLogin.check(companyId, userId, intent, localSessionId));
});
router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", async (req, res) => {
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<typeof supportsLo
res.setHeader("Cache-Control", "no-store");
res.json({ ok: true });
});
router.get("/companies/:companyId/ai-connections", async (req, res) => {
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<typeof supportsLo
} satisfies AiConnectionList);
});
router.get(
"/companies/:companyId/ai-connections/:connectionId/usage",
"/companies/:companyId/ai-connections/:connectionId/usage", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/:connectionId/usage", { personal: true }),
async (req, res) => {
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<typeof supportsLo
eq(toolConnections.connectionPurpose, "ai"),
),
);
if (!connection || !(await service.list(companyId, getActorInfo(req).actorId)).some(account => 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<typeof supportsLo
},
);
router.post(
"/companies/:companyId/ai-connections",
"/companies/:companyId/ai-connections", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections", { personal: true }),
validate(createAiConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -316,12 +318,13 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
input.apiKey ?? "",
undefined,
attemptStartedAt,
connectionRequestContext(req),
);
res.status(201).json(result);
},
);
router.post(
"/companies/:companyId/ai-connections/local",
"/companies/:companyId/ai-connections/local", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local", { personal: true }),
validate(localAiConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -331,18 +334,18 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
if (!localSessionId) throw unprocessable(input.provider === "xai"
? "Start local sign-in for this connection before connecting."
: "Start browser sign-in for this connection before connecting.");
res.status(201).json(await localLogin.complete(companyId, userId, localSessionId, input));
res.status(201).json(await localLogin.complete(companyId, userId, localSessionId, input, connectionRequestContext(req)));
},
);
router.put(
"/companies/:companyId/ai-connections/default",
"/companies/:companyId/ai-connections/default", managementGuard("connections:configure", "PUT /companies/:companyId/ai-connections/default", { personal: true }),
async (req, res) => {
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<typeof supportsLo
await service.setDefault(companyId, userId, req.body.grantId);
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
...connectionRequestActor(req),
action: "ai_connection.default_changed",
entityType: "connection_grant",
entityId: req.body.grantId,
@@ -361,11 +363,23 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
res.json({ ok: true });
},
);
router.get("/companies/:companyId/ai-connections/login/:sessionId/setup", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/login/:sessionId/setup", { personal: true }), async (req, res) => {
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<typeof supportsLo
.where(
and(
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, getActorInfo(req).actorId),
eq(adapterAuthSessions.startedByUserId, responsibleUserForAiRequest(req)!),
eq(
adapterAuthSessions.publicSessionId,
req.params.sessionId as string,
+121 -83
View File
@@ -1,3 +1,8 @@
import { connectionManagementService } from "../services/connection-management.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { companies } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { requiredConnectionUserAction, filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared";
import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js";
import {
@@ -41,6 +46,7 @@ import {
} from "../services/invite-rate-limit.js";
import {
assertBoard,
assertBoardOrAgent,
assertCompanyAccess,
getAccessibleResource,
getActorInfo,
@@ -68,8 +74,7 @@ function endpointId(req: ExpressRequest): string {
}
function actorUserId(req: ExpressRequest): string | null {
const actor = getActorInfo(req);
return actor.actorType === "user" ? actor.actorId : null;
return connectionRequestUserId(req);
}
async function assertEndpointAccess(
@@ -77,7 +82,7 @@ async function assertEndpointAccess(
res: ExpressResponse,
service: ChatChannelService,
): Promise<boolean> {
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);
});
@@ -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<Request, ConnectionManagementContext>();
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<string>();
const extraResources: ConnectionManagementResource[] = [];
const selectors: Record<string, unknown>[] = [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<T>(db: Db, req: Request, action: ConnectionManagementAction,
rows: T[], resource: (row: T) => Partial<ConnectionManagementResource>): Promise<T[]> {
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<T extends { selectors: Record<string, unknown> }>(db: Db, req: Request, rows: T[]): Promise<T[]> {
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<ConnectionManagementResource>[] = [{}];
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;
}
+13 -9
View File
@@ -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({
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+45 -30
View File
@@ -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,
+2 -1
View File
@@ -68,6 +68,7 @@ export interface LogActivityInput {
issueId?: string | null;
details?: Record<string, unknown> | 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,
+7 -4
View File
@@ -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 };
}
+5 -2
View File
@@ -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<void> },
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",
+78 -11
View File
@@ -54,6 +54,10 @@ function measureWorkspaceAuthorization<T>(phase: Extract<ExecutionPhase, `author
return isRunPhaseActive("workspace.access.task_authorization") || isRunPhaseActive("workspace.access.authorization")
? measureRunPhase(phase, work) : work();
}
import {
CONNECTION_MANAGEMENT_PERMISSIONS, isConnectionManagementAction,
type ConnectionManagementAction, type ConnectionManagementResource,
} from "./connection-management-policy.js";
export type AuthorizationActor =
{
@@ -87,6 +91,7 @@ export type AuthorizationActor =
export type AuthorizationAction =
| PermissionKey
| ConnectionManagementAction
| "agent_config:read"
| "agent_config:update"
| "agent_instructions:update"
@@ -108,6 +113,7 @@ export type AuthorizationAction =
| "secrets:propose";
export type AuthorizationResource =
| ConnectionManagementResource
| { type: "company"; companyId: string }
| { type: "agent"; companyId: string; agentId?: string | null }
| { type: "project"; companyId: string; projectId?: string | null }
@@ -180,6 +186,7 @@ function companyIdForResource(resource: AuthorizationResource) {
}
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
if (isConnectionManagementAction(action)) return CONNECTION_MANAGEMENT_PERMISSIONS[action];
if (action === "project:write_workspace") return "tasks:assign";
if (action === "agent_config:read" || action === "agent_config:update" || action === "agent_instructions:update" || action === "skill_config:update") {
return null;
@@ -442,6 +449,18 @@ async function scopeAllows(
const requestedUserId = typeof requestedScope.userId === "string" ? requestedScope.userId : null;
let constrained = false;
for (const key of ["connection", "profile", "gateway", "provider", "purpose", "operation"] as const) {
const idKey = ["connection", "profile", "gateway"].includes(key) ? `${key}Id` : key;
const values = scopeValuesForKeys(grantScope, [idKey, `${idKey}s`]);
if (values.length === 0) {
if (Object.hasOwn(grantScope, idKey) || Object.hasOwn(grantScope, `${idKey}s`)) return false;
continue;
}
constrained = true;
const requested = requestedScope[idKey];
if (!scopeIncludesId(values, typeof requested === "string" ? requested : null)) return false;
}
const projectIds = [
...scopeValueList(grantScope.projectId),
...scopeValueList(grantScope.projectIds),
@@ -1096,6 +1115,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
if (!runId) return null;
const row = await db
.select({
issueId: heartbeatRuns.issueId,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: sql<Record<string, unknown>>`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<TrustPresetResolution> {
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<AuthorizationDecision> {
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(
+2 -1
View File
@@ -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,
+22 -3
View File
@@ -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<string> | undefined;
if (managementContext?.actor.type === "agent") {
const conversations = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpointId));
const visibleConversations = new Set<string>();
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 {
@@ -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"
+12 -7
View File
@@ -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,
@@ -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<typeof createChatResourceReconciliation>["reconcileProviderResourceRows"];
listResources: ReturnType<typeof createChatResourceReconciliation>["listResources"];
replaceResources: ReturnType<typeof createChatResourceReconciliation>["replaceResources"];
update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null) => Promise<ChatEndpoint>;
configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null) => Promise<ChatEndpoint>;
update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise<ChatEndpoint>;
configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise<ChatEndpoint>;
test: ReturnType<typeof createSetupProbes>["test"];
githubWizardOrigin?: string | null;
handleWebhook: (publicId: string, provider: "github", request: Request) => Promise<Response>;
@@ -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<string, string>, source: "manifest" | "existing_app" = "existing_app") {
async function storeGitHubApp(endpointId: string, userId: string, credentials: Record<string, string>, 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,
@@ -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(
@@ -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,
@@ -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 },
});
@@ -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,
@@ -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<void>;
},
) {
@@ -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" })
+86 -43
View File
@@ -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<string, unknown>;
@@ -81,16 +84,18 @@ export function githubChatWizardService(
resumeDirect?: (
endpointId: string,
userId: string,
managementContext?: ConnectionManagementContext,
) => Promise<{
registrationUrl: string;
manifest: Record<string, unknown>;
expiresAt: string;
}>;
completeDirect?: (state: string, code: string) => Promise<string>;
completeDirect?: (state: string, code: string, managementContext?: ConnectionManagementContext) => Promise<string>;
storeApp: (
endpointId: string,
userId: string,
credentials: Record<string, string>,
managementContext?: ConnectionManagementContext,
) => Promise<unknown>;
storeCredentials: (
endpointId: string,
@@ -100,16 +105,17 @@ export function githubChatWizardService(
refreshRepositories: (
endpointId: string,
userId: string,
managementContext?: ConnectionManagementContext,
) => Promise<Resource[]>;
resources: (endpointId: string) => Promise<Resource[]>;
replaceResources: (
endpointId: string,
updates: Array<{ id: string; enabled: boolean }>,
userId: string,
options?: { initialGitHubImport?: boolean },
options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext },
) => Promise<unknown>;
configure: (endpointId: string, userId: string) => Promise<unknown>;
finish: (endpointId: string, userId: string) => Promise<unknown>;
configure: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise<unknown>;
finish: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise<unknown>;
},
) {
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<GitHubAppWizardState> {
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<GitHubAppWizardState> {
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<void>,
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<void>,
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(
+60 -6
View File
@@ -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<ConnectionIntentSetupOptions> {
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));
@@ -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<string, PermissionKey>;
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[];
}
@@ -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<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId" | "responsibleUserIdOverride" | "authorization">;
reauthorize: (continuation?: { afterHumanAction: true }) => Promise<void>;
};
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<ConnectionManagementContext> {
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<typeof authorize>) {
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 }) };
}
@@ -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<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
patch: Partial<Registration> = {}, 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];
@@ -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<string, unknown>, workspaceId: string) =>
export function slackTestMessageService(db: Db, options: Options) {
const api = createSlackSetupClient(options.fetch);
return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise<SlackTestMessageResult> => {
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 };
});
@@ -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<StartDeviceLoginResult> {
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();
+16
View File
@@ -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)
+17 -10
View File
@@ -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,
+11 -7
View File
@@ -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<LocalAiLoginAttempt> {
async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false, managementContext?: ConnectionManagementContext): Promise<LocalAiLoginAttempt> {
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 },
});
@@ -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 } : {}),
@@ -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)
@@ -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<typeof fetch>(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) =>
@@ -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
@@ -30,5 +30,5 @@ export async function completeSetupTokenAiConnection(
});
if (!transitioned) throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED);
},
});
}, undefined, scope.managementContext);
}
@@ -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;
+28 -24
View File
@@ -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<ConnectToolAppResult> {
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<FinishToolAppResult> {
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<string, string> },
actor?: ActorInfo,
): Promise<ToolConnectionHealthCheckResult> {
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<ToolStdioCommandTemplate> => {
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<ToolConnection> => {
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<RailwaySshSetup | null> => {
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);
@@ -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<ToolConnectionActivityResponse> => {
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 = [
+4
View File
@@ -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;
}
@@ -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<AggregatorAppsResponse["discovery"]> {
@@ -262,7 +265,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred
}
async function aggregatorAppsResponse(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise<AggregatorAppsResponse> {
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<AggregatorAppsResponse> {
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);
@@ -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));
@@ -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, {
@@ -58,6 +58,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
credentialHeaders?: Record<string, string>;
} = {},
): Promise<ToolCatalogRefreshResult> {
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);
@@ -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
@@ -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<ToolConnectionInstallSnapshot> => {
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 });
@@ -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) {
@@ -228,6 +228,7 @@ async function removeConnection(
companyId?: string,
actor?: ActorInfo,
): Promise<ToolConnectionRemovalResult> {
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<ToolConnectionRemovalResult> {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
forgetMcpHttpSessions(connection.id);
const now = new Date();
@@ -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<typeof connectionGrants.$inferSelect | null> {
const actorUserId =
actor?.actorType === "user" ? (actor.actorId ?? null) : null;
connectionActorUserId(actor);
if (actorUserId) {
const [personal] = await db
.select()
@@ -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,
@@ -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<FinishToolAppResult> {
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");
@@ -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<ToolOAuthStartResult> {
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(
@@ -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();
@@ -366,6 +366,7 @@ const oauthRefreshFlights = new Map<string, Promise<unknown>>();
heartbeatRunId?: string | null;
},
): Promise<typeof toolConnections.$inferSelect> {
await actor?.managementContext?.reauthorize();
const oauth = oauthConfig(connection);
if (
typeof oauth.tokenUrl !== "string" ||
@@ -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"),
),
)
@@ -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 = {
@@ -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<ToolProfileNewToolsReviewResult> {
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<ToolProfileWithDetails> => {
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<ToolProfileWithDetails> => {
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<ToolProfileWithDetails> => {
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<ToolProfileEntry> => 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<ToolProfileEntry> => 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<ToolProfileEntry> => db.transaction(async tx => {
deleteProfileEntry: async (entryId: string, managementContext?: ConnectionManagementContext): Promise<ToolProfileEntry> => 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<ToolProfileBinding> => 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");
@@ -114,6 +114,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick<ReturnTyp
action: "stop" | "restart";
actor?: ActorInfo;
}): Promise<ToolRuntimeSlot> {
await input.actor?.managementContext?.reauthorize();
try {
if (input.action === "stop") {
await runtimeSupervisor.stopSlot({
@@ -132,6 +133,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick<ReturnTyp
companyId: input.companyId,
actorType: input.actor?.actorType ?? "system",
actorId: input.actor?.actorId ?? "tool-access-service",
...input.actor?.managementContext?.audit,
action:
input.action === "stop"
? "tool_runtime_slot.operator_stopped"
+4
View File
@@ -1,6 +1,9 @@
import type { ConnectionManagementContext } from "../connection-management.js";
import type { Db } from "@paperclipai/db";
export type ActorInfo = {
managementContext?: ConnectionManagementContext;
responsibleUserId?: string | null;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
sessionId?: string | null;
@@ -14,6 +17,7 @@ export type ActorInfo = {
| "cloud_tenant";
};
export type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type ToolAccessMutationDb = Pick<
@@ -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()
+113 -22
View File
@@ -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<void> {
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<ToolMcpGatewayWithTokens> {
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<ToolMcpGatewayWithTokens> {
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<ToolMcpGatewayTokenCreated> {
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<ToolMcpGatewayToken> {
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(
+1
View File
@@ -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<AiConnectionUsage>(`/companies/${companyId}/ai-connections/${connectionId}/usage${grantId ? `?grantId=${encodeURIComponent(grantId)}` : ""}`),
startLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { restart?: boolean }) => api.post<LocalAiLoginAttempt>(`/companies/${companyId}/ai-connections/local/attempts`, input),
checkLocalLogin: (companyId: string, input: AiConnectionLoginIntent & { localSessionId?: string }) => api.post<LocalAiLoginStatus>(`/companies/${companyId}/ai-connections/local/check`, input),
@@ -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"],
[
@@ -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."}
</p>
@@ -486,7 +488,10 @@ export function ConnectionIntentInteractionBody({
</p>
) : null}
{isEmail ? setupQuery.isLoading || setupQuery.isError ? <>
{isChatHandoff ? <div className="mt-4 flex items-center justify-between gap-2">
<Button type="button" variant="ghost" disabled={declineMutation.isPending} onClick={() => declineMutation.mutate()}>Not now</Button>
{setupQuery.data?.chatSetup ? <Button asChild><a href={setupQuery.data.chatSetup.path} target="_blank" rel="noopener noreferrer">Continue authentication</a></Button> : setupContent}
</div> : isEmail ? setupQuery.isLoading || setupQuery.isError ? <>
{setupContent}
<Button type="button" variant="ghost" disabled={declineMutation.isPending} onClick={() => declineMutation.mutate()}>Not now</Button>
</> : <AgentMailIntentSetup
+22
View File
@@ -445,6 +445,28 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
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 });
+14
View File
@@ -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 <p role="status">Loading saved sign-in…</p>;
if (!login.data) return <p role="alert">{login.error?.message ?? "This sign-in is unavailable."}</p>;
const { intent, environmentId, connectionId, grantId } = login.data;
if (connectionId && grantId) return <div className="space-y-4"><p role="status">This account is connected.</p><Button onClick={() => navigate(`/apps/${connectionId}/permissions`)}>View connection</Button></div>;
return <AiConnectionCredentialStep key={aiLoginId} companyId={selectedCompanyId} {...intent}
agentIds={intent.agentIds ?? []} allAgents={intent.allAgents ?? false} initialMethod={intent.method} fixedMethod
environmentId={environmentId} hideName onCancel={() => navigate("/apps")}
onComplete={binding => navigate(`/apps/${binding.connectionId}/permissions`)} />;
}
if (aiReconnectRequested && aiAccounts.isPending) return <p role="status">Loading connection…</p>;
if (aiReconnectRequested && aiAccounts.isError) return <p role="alert">Could not load this connection. Refresh to try again.</p>;
if (aiReconnectRequested && !aiReconnect) return <p role="alert">This connection is unavailable. Return to Connectors to choose an account.</p>;