From 13709b4c7af89cadd5f5516ae453778e66af8f10 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:20:24 -0500 Subject: [PATCH] feat(connections): let agents configure connections within delegated authority (#17737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connections provide governed tools, chat channels, model accounts, and gateways. > - Several management routes require a human board actor even when an agent has the required grant. > - Default agents must configure these resources within the responsible user's authority. > - This pull request adds a shared authorization path to the existing REST and native API surface. > - Human authentication, execution approvals, and local host boundaries remain enforced. > - The benefit is that a standard agent can complete authorized setup with a bounded human handoff. ## Linked Issues or Issue Description **Subsystem affected** Server authorization, connection services, shared contracts, native API discovery, database schema. **Problem or motivation** Connection administration is available in the web app but often unavailable to agents. Agent grants, responsible-user authority, credential ownership, and actor attribution need separate checks. Opening gateway-token creation also requires preserving the issuing agent's delegated authority during token use. **Proposed solution** Use the existing authorization evaluator for every connection family. Enforce current responsible-user authority, effective trust, grant scope, resource visibility, and target-agent access. Keep existing agent grants unchanged. Expose authorized configuration through default `search_api` and `call_api`. Save human authentication steps and let agents continue after completion. **Roadmap alignment** This change completes the API access path for the existing Connected Apps, Personal & Shared AI Accounts, and governed MCP Gateway capabilities in `ROADMAP.md`. It follows the maintainer's requested design. Related work: #11899 enforces connection execution access and creator/manager rules; this PR extends agent management across connection families. It does not replace that execution-policy work. ## What Changed - Add shared connection management context and mandatory responsible-user, trust, scope, and target checks. - Allow authorized agents to configure tool, chat/email, GitHub/Slack, AI, profile, and gateway resources through existing routes. - Preserve human credential ownership and record the actual agent, responsible user, and run or key in audit data. - Preserve saved browser authentication handoffs and callback session checks. - Bind agent-issued durable gateway tokens to their responsible user and recheck delegated authority at use. - Add explicit REST transport and management capability metadata, built-in guidance, and sensitive-response handling. - Intersect caller and endpoint-agent Slack test/invitation policies, including independent and shared rates. Require the addressed human’s current endpoint identity link before completing identity handoffs. - Add production-authenticated integration scenarios. Companion #17739 contains the saved setup UI (including the required human import/rotation step), product and permission documentation, catalog/Vercel credential and callback ownership, and operation maps to keep each review below 100 files. Both PRs must ship together; merge this implementation first, then its companion. ## Verification - Current implementation head: `2ecd6451d0d0c8d82121a4e3fa4ce94c5befbbd6`, including master `ea727dc3c2`, 99 files. The latest follow-up fixes the two valid findings from the preceding f9 review. Exact-head Greptile has completed at5/5 with no findings and Superagent passed. Full exact-head Linux CI workflow40446606056 completed SUCCESS; only the queued external Paperclip Review remains pending; earlier Linux passes do not qualify this successor. - Latest preserved-stack verification passed **2,165 tests** in fresh PostgreSQL: **70 actual-auth scenarios**, 27 OpenAPI contracts, 2,022 native API tests and 46 saved GitHub wizard cases. **141 existing gateway/policy acceptance tests** also passed. Workspace typecheck/build, maps and token gates passed. The first workspace typecheck was blocked by sandbox tsx IPC permissions; its authorized rerun passed. This is focused local evidence; the full Linux CI matrix is still required. - Issuer/target gateway policy regressions were red before the fix. Both decisions now intersect, including Block/Ask first/rates, same credential identity and current token/user/agent authority after approval. Invocations retain their token and actual requesting agent; deleted tokens fail closed. Thirteen actual-auth cases cover revocation, narrowed scope, approvals, shared rates and board compatibility. Intermediate missing-token attribution, review-context and implicit-session actor failures remain in the qualification record; all final cases pass. - Activity pagination now retains a bounded cursor across private-only windows. The actual-JWT regression reaches three older readable events after 27 private ones without exposing private details. A native device-login regression was red for plaintext receipts; local check and test-call results now use existing protected-response handling. The passing actual-auth case proves encrypted persistence, exact replay, redacted history and no response asset. - Previous-head `5b55195593` Linux CI initially failed e2e shard 4/8 in the existing connection-intent continuation test: periodic recovery queued a repair at 01:21:47, the scripted journey completed the task at 01:21:48, and the extra queued run was cancelled at 01:21:49 because the task was terminal. The assertion selected that latest cancelled run. GitHub refused the first attempt while the workflow was nonterminal. After completion, the single evidence-supported failed-job rerun was accepted as **attempt 2** on the same head (workflow 40431202466). The failed shard and final E2E aggregate passed on attempt 2, and the complete Linux workflow for that head is successful. The original failure remains part of qualification. No additional unchanged-code rerun will be used for this failure. - Companion CI also has an unchanged login-cleanup idleness timeout, retained in its qualification record. That same 16-case suite passed in this parent Linux run and a fresh local database. Both preceding workflows passed after their single failed-job reruns as attempt 2; the original failures remain recorded. No additional unchanged-code rerun was used. - Migration `0380_broken_aqueduct.sql` was regenerated through the repository workflow after master took 0379 for project Git authorization. It adds the responsible-user column with `IF NOT EXISTS`, preserves upstream migrations, and satisfies snapshot retention and migration safety checks. - Upstream published Copilot v62 and v63 identities remain byte-for-byte unchanged. The combined guidance is now v64, digest `sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a`. The normal shared TypeScript/Rust identity check and **61 Copilot installation tests passed**. The old source-identity assertion first failed against unchanged published v63; regenerating a new v64 preserves the published release. - Workspace `pnpm -r typecheck` and `pnpm build`, map validation, and UI token gates passed on the final combined stack after the upstream merge. Node 24.22.1 / pnpm 9.15.4 were used. - **Earlier 82 API/OpenAPI tests passed** in fresh PostgreSQL 18: 55 assembled-server scenarios and 27 contracts. They use real persistent agent keys, run JWTs, human sessions, production defaults, and deterministic provider fixtures. No actor injection establishes access. - The new email regression was red before per-account scope filtering. The Arcade regression was red before human ownership and inventory-key fixes; it also verifies actual-agent audit identity and a human-session inventory read. The activity regression measured **200 authorization checks before the fix and one afterward** for the same one-item page. - **82 GitHub UI tests passed**, including all three new failed-replacement regressions that were red before their fix. The UI preserves an earlier copy only after confirming its committed generation. Changed or unreadable results block copying and reconnecting until reconciled. Human-only secret responses now include their nonsecret committed generation. - Private test status now rechecks the originating principal and linked task, including mandatory agent/responsible-user privacy. Cross-agent tests require the full caller tool access decision and the same credential identity, regardless of auth label; caller and target policies/approvals and rate limits apply before execution and after human approval. A profile-only precondition was found to reject valid allow-policy and scoped-grant access and has been removed. Both permitted-access cases and a no-auth-label identity regression were red before the correction; caller-specific Block still prevents provider execution. The private-status regression was also red before its fix. - The tool-activity regression measured 100 capability decisions and 102 run captures on a 100-event page before the fix. It now uses two task decisions and at most two captures while retaining the same 50 visible events, private-event filtering, and 403 after audit-grant revocation. The regression was red before request-context reuse and task-decision sharing; final server typecheck/build also pass. - Generic chat configure/reconnect now hands agent-supplied verification credentials to the addressed human before provider work or vault changes. Actual key/JWT regressions were red for Slack signing-key replacement and generic channel secret-reference rebinding. They now prove unchanged refs and vaulted signing value, human setup, permitted agent bot-token repair, and governed test messages. All 82 API/OpenAPI cases pass in fresh PostgreSQL. Eight native receipt protections and final server typecheck/build also pass. - The preceding review found that old healthy state could retire a credential-entry card before a human changed anything. The actual-JWT regression was red. Saved field generations now change only with protected values and commit with refs plus a verification fence; retries retain the baseline. Old health, bot-token repair and unchanged entry stay pending. New signing verification and current authority permit continuation. A separate red race proved that user revocation during provider authentication could return 403 after refs changed; authority is now checked before persistence and inside the credential transaction. The final 82 API/OpenAPI cases, four neighboring real-database Slack/GitHub lifecycle cases, and final server typecheck/build pass. - A preceding full run crossed a rate-window boundary. Database rows prove one consumed slot at 23:35 and another at 23:36 for the same policy/bucket. Only Date is frozen in the rate test cases; transport and asynchronous timers remain real. Production rate behavior is unchanged, and the final full run passes. An intermediate fixture failure that overwrote hidden generation metadata with an API projection is retained in the qualification record. - Linux CI on an earlier head found two rate-admission assertions that omitted the newly persisted bucket key. The failures were reproduced locally; both assertions now verify the exact actor/tool bucket and all 49 policy-service tests pass. The companion qualification record retains the initial and intermediate failures. Catalog creator attribution is preserved in the companion to keep this implementation review at 99 files. - The latest review reproduced three regressions before repair: complete human replacements failed on unavailable old signing secrets, fresh GitHub links incorrectly selected reconnect, and different credential-entry requests shared an idempotency key. Each old protected ref now has a narrow known-unavailable fallback; unexpected resolver failures and company/scope violations still abort. Fresh imports keep their credential form, configured endpoints keep reconnect, and canonical field sets/completion modes distinguish handoffs while matching retries retain their baseline. All 55 actual-auth scenarios and 27 separately run OpenAPI contracts pass, as do server typecheck/build. Companion #17739 also passes all 41 wizard tests including actual fresh-form submission, UI typecheck, maps and token gates. Earlier red results and a mistyped OpenAPI filename are retained in qualification. - The configured GitHub handoff regression was red in the browser-component tests and actual-auth link/persistence tests. Saved links now name requested fields and all/any completion mode; the companion form submits those supplied fields through reconnect and retains other saved credentials. Webhook/client-secret reconnect remains human-only; first-setup secret restrictions remain. All 46 wizard tests pass, including combined modes and legacy links. A duplicate review referenced the older companion bf760912; the current form is already fixed, linked in the reply, and its thread is resolved. - Linux CI on bf760912 failed automatic Slack provisioning because vault persistence cleared an already verified manifest-update challenge. The same regression was reproduced locally. The provider-owned helper now preserves proof only for the current signing fingerprint and callback URL. Eight existing compatibility cases pass, including this case and initial GitHub secret rejection. The first uncommitted GitHub-field variant also failed that initial boundary and was narrowed to reconnect; both failures remain in qualification. - The upstream merge conflicted only in generated migration metadata. Upstream 0379 is preserved, normal generation/pruning produced 0380, and two repeated applications of the idempotent additive SQL passed. The final combined stack passed **128 tests** (55 actual-auth, 27 OpenAPI and 46 wizard cases) in fresh PostgreSQL. Eight Google UI cases also pass; the two protocol cases could not initialize embedded PostgreSQL in this fresh local attempt. Their earlier passing evidence remains historical; fresh Linux CI must execute them. Vercel personal credential attribution is preserved in companion #17739 with its other ownership/consent follow-ups, keeping this PR at 99 files. - The suite covers fresh defaults, restricted users/agents, scopes, personal isolation, token revocation, human handoffs, callback replay, execution approvals, authorized self-targeted policy changes, self-approval denial, and current authority before commit. Reproduce API checks with `pnpm exec vitest run server/src/__tests__/connection-management.integration.test.ts server/src/__tests__/openapi-routes.test.ts`, using a fresh database through `PAPERCLIP_CONNECTION_MANAGEMENT_TEST_DATABASE_URL` or the default embedded test database. - Companion #17739 also records consent separately from token refresh so saved Google choices survive refresh. Its eight service-picker and two local-HTTP OAuth tests pass; both new UI cases were red before the fix. Normal migration generation confirms no SQL change is needed for the additive JSON metadata. - The integration module shares one assembled server, authentication factory, and provider lifecycle to preserve the HTTP-to-vault-to-task-continuation proof. A reused external database caused a Slack fixture identity conflict; the fresh final run passed. Earlier fixture/transport and contract-update failures remain in the companion qualification record. - The broad local `pnpm test:run` attempt remains blocked by macOS embedded PostgreSQL's host semaphore limit. It is not a clean pass. The fresh Linux CI matrix must complete. Provider fixtures do not prove live setup or an empirical LLM success rate. - A current-head completed Paperclip assessment is not yet recorded. Its earlier external recovery boundary has not been bypassed, and no incomplete assessment is reported as passing. The user authorized both merges when checks pass; merge this implementation first, then replay, retarget, and requalify companion #17739 against the squash-merged master. - After the master refresh, local workspace typecheck/build, **97 actual-auth/OpenAPI cases** and **141 gateway/policy cases** pass. The original fixed database port was occupied; Docker assigned a free localhost port. The first neighbor setup command could not resolve root `tsx`, leaving its schema uninitialized; the built database package migrated the separate fixture and all unchanged cases passed. Both setup failures are retained in qualification. Fresh Linux CI remains required; no full local test-sweep pass is claimed. - The f9 Greptile review scored **2/5** for two reproduced findings: Slack test sends skipped the caller’s tool policy, and healthy App credentials completed an unconfirmed identity handoff. Both are fixed in this head and have resolved evidence threads ([Slack](https://github.com/paperclipai/paperclip/pull/17737#discussion_r4384927765), [identity](https://github.com/paperclipai/paperclip/pull/17737#discussion_r4384927895)). Actual key/JWT cases cover twelve Slack caller/target/recipient combinations; the human-session GitHub OAuth callback alone remains pending until explicit confirmation. All **99 API/OpenAPI tests** (72 actual-auth + 27 contracts), workspace typecheck and build pass. On the combined companion, all four focused cases (including the twelve Slack combinations), typecheck/build and maps also pass. The continuation suite’s 50 embedded-PG cases were skipped because its database could not start locally; fresh Linux CI must run them. Initial fixture credential-collision and audit-projection assertion failures are retained with the red reproductions in the companion qualification record. No full local sweep or live consent is claimed. ## Risks - The maintainer explicitly confirmed authorized policy administration, including self-targeted changes that loosen controls. These writes require management grants and the responsible user’s current authority. Agents cannot approve their own governed action requests or edit their own principal permission grants. The actual-auth regression exercises both permitted administration and those denials. - This is a broad authorization change. Incorrect scope or owner resolution could deny permitted setup or expose another user's resources. Integration cases cover both permitted and denied requests with actual authentication. - The additive nullable gateway-token column does not infer identities for legacy clients or backfill agent grants. Legacy agent tokens without a responsible user fail closed. - Provider fixtures test supported setup and callback contracts. They do not prove live provider availability. ## Model Used - OpenAI Codex, GPT-6 family, with reasoning, repository tools, and code execution. The runtime does not expose a more specific deployment ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass — focused suites above; full local sweep remains blocked as documented - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green — exact `2ecd6451d0` Linux CI completed SUCCESS; external Paperclip Review is still queued - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups — exact `2ecd6451d0` review completed; both f9 findings have resolved evidence replies - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/migrations/0380_broken_aqueduct.sql | 1 + ...{0374_snapshot.json => 0380_snapshot.json} | 41 +- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/chat_github.ts | 2 + packages/db/src/schema/tool_access.ts | 2 + packages/paperclip-runner/acpx-profiles.json | 4 +- .../src/generated_acpx_profiles.rs | 2 +- .../scripts/copilot-profile-identity.mjs | 4 +- .../src/contracts/runtime-context-prompts.ts | 4 +- .../drivers/acpx/copilot-installation.test.ts | 19 +- .../src/drivers/acpx/generated-profiles.ts | 4 +- .../src/drivers/acpx/profile-compatibility.ts | 4 +- .../copilot-profile-v64-identity.json | 85 + .../src/connection-intent-guidance.test.ts | 4 +- .../shared/src/connection-intent-guidance.ts | 8 +- packages/shared/src/index.ts | 1 + .../shared/src/types/connection-intent.ts | 21 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 2 + packages/shared/src/types/tool-access.ts | 8 +- packages/shared/src/validators/issue.ts | 5 + .../agent-hire-ai-connections.test.ts | 3 + .../__tests__/ai-connection-router.test.ts | 4 + .../chat-channels.integration.test.ts | 9 +- .../connection-management.integration.test.ts | 1560 +++++++++++++++++ server/src/__tests__/decision-models.test.ts | 2 + .../src/__tests__/email-address-check.test.ts | 15 +- .../email-routes-provider-errors.test.ts | 10 +- .../__tests__/helpers/runner-api-server.ts | 36 +- server/src/__tests__/openapi-routes.test.ts | 45 +- .../task-connector-read-routes.test.ts | 18 +- .../tool-access-policy-service.test.ts | 11 +- .../src/__tests__/tool-access-service.test.ts | 16 +- server/src/__tests__/tool-gateway.test.ts | 18 +- server/src/app.ts | 6 +- server/src/routes/agents.ts | 92 +- server/src/routes/ai-connections.ts | 156 +- server/src/routes/chat-channels.ts | 244 ++- .../src/routes/connection-management-authz.ts | 473 +++++ server/src/routes/email.ts | 22 +- server/src/routes/openapi.ts | 440 +++-- server/src/routes/tool-access.ts | 603 +++---- server/src/routes/tool-gateway.ts | 75 +- server/src/services/activity-log.ts | 3 +- server/src/services/ai-connection-router.ts | 11 +- server/src/services/ai-connections.ts | 7 +- server/src/services/authorization.ts | 89 +- server/src/services/chat-channels.ts | 12 +- server/src/services/chat-channels/activity.ts | 59 +- .../services/chat-channels/configuration.ts | 58 +- .../src/services/chat-channels/credentials.ts | 53 +- .../src/services/chat-channels/endpoints.ts | 22 +- .../chat-channels/github/app-setup.ts | 29 +- .../chat-channels/github/setup-secret.ts | 46 +- .../chat-channels/resource-reconciliation.ts | 11 +- .../services/chat-channels/setup-probes.ts | 7 +- .../slack/callback-verification.ts | 9 + server/src/services/chat-github-management.ts | 6 +- .../src/services/chat-github-registration.ts | 18 +- server/src/services/chat-github-wizard.ts | 129 +- server/src/services/connection-intents.ts | 101 +- .../services/connection-management-policy.ts | 41 + server/src/services/connection-management.ts | 332 ++++ .../connectors/slack/setup/registration.ts | 7 +- .../services/connectors/slack/test-message.ts | 39 +- server/src/services/device-login-service.ts | 8 + server/src/services/email-channels.ts | 16 + server/src/services/email-connections.ts | 35 +- .../src/services/issue-thread-interactions.ts | 21 +- server/src/services/local-ai-login.ts | 18 +- .../native-runtime/runner-api-catalog.ts | 6 +- .../runner-api-sensitive-response.ts | 2 + .../native-runtime/runner-api.test.ts | 33 +- .../services/satisfied-connection-intents.ts | 48 +- .../src/services/setup-token-ai-connection.ts | 2 +- server/src/services/setup-token-session.ts | 2 + server/src/services/tool-access-policy.ts | 60 +- server/src/services/tool-access.ts | 67 +- .../services/tool-access/activity/service.ts | 21 +- server/src/services/tool-access/actor.ts | 4 + .../catalog/aggregator-accounts.ts | 75 +- .../services/tool-access/catalog/health.ts | 11 +- .../tool-access/catalog/publication.ts | 1 + .../tool-access/connections/grants.ts | 18 +- .../tool-access/connections/installs.ts | 6 +- .../connections/personal-identity.ts | 3 +- .../tool-access/connections/removal.ts | 2 + .../services/tool-access/credential-access.ts | 3 +- .../tool-access/github-grant-continuity.ts | 2 + .../tool-access/oauth/access-finalization.ts | 4 +- .../tool-access/oauth/authorization-start.ts | 39 +- .../tool-access/oauth/catalog-completion.ts | 5 +- .../tool-access/oauth/connection-refresh.ts | 1 + .../tool-access/oauth/credential-store.ts | 6 +- .../services/tool-access/profiles/service.ts | 32 +- .../src/services/tool-access/runtime/slots.ts | 2 + server/src/services/tool-access/types.ts | 4 + server/src/services/tool-action-review.ts | 2 +- server/src/services/tool-gateway.ts | 360 +++- 99 files changed, 5006 insertions(+), 1089 deletions(-) create mode 100644 packages/db/src/migrations/0380_broken_aqueduct.sql rename packages/db/src/migrations/meta/{0374_snapshot.json => 0380_snapshot.json} (99%) create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v64-identity.json create mode 100644 server/src/__tests__/connection-management.integration.test.ts create mode 100644 server/src/routes/connection-management-authz.ts create mode 100644 server/src/services/connection-management-policy.ts create mode 100644 server/src/services/connection-management.ts diff --git a/packages/db/src/migrations/0380_broken_aqueduct.sql b/packages/db/src/migrations/0380_broken_aqueduct.sql new file mode 100644 index 00000000000..529f7de5c12 --- /dev/null +++ b/packages/db/src/migrations/0380_broken_aqueduct.sql @@ -0,0 +1 @@ +ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "responsible_user_id" text; diff --git a/packages/db/src/migrations/meta/0374_snapshot.json b/packages/db/src/migrations/meta/0380_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0374_snapshot.json rename to packages/db/src/migrations/meta/0380_snapshot.json index c00a314397e..5e635e8be52 100644 --- a/packages/db/src/migrations/meta/0374_snapshot.json +++ b/packages/db/src/migrations/meta/0380_snapshot.json @@ -1,6 +1,6 @@ { - "id": "945c31ed-f39f-4272-a91d-85de925f5361", - "prevId": "406521f8-5352-415b-805d-786854381e03", + "id": "bfda87fa-662b-47bb-87c0-dabc814aebd7", + "prevId": "2a9439e3-4a2b-4a20-ab2b-2aee3db8cb03", "version": "7", "dialect": "postgresql", "tables": { @@ -28608,8 +28608,8 @@ "method": "btree", "with": {} }, - "execution_workspace_restore_points_live_checkout_uniq": { - "name": "execution_workspace_restore_points_live_checkout_uniq", + "execution_workspace_restore_points_live_status_uniq": { + "name": "execution_workspace_restore_points_live_status_uniq", "columns": [ { "expression": "execution_workspace_id", @@ -28631,7 +28631,7 @@ } ], "isUnique": true, - "where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'", + "where": "\"execution_workspace_restore_points\".\"status\" in ('pending', 'active', 'expiring')", "concurrently": false, "method": "btree", "with": {} @@ -47782,6 +47782,18 @@ "primaryKey": false, "notNull": false }, + "git_authorization_grant_id": { + "name": "git_authorization_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "git_authorized_by_user_id": { + "name": "git_authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "pause_reason": { "name": "pause_reason", "type": "text", @@ -47920,6 +47932,19 @@ ], "onDelete": "no action", "onUpdate": "no action" + }, + "projects_git_authorization_grant_id_connection_grants_id_fk": { + "name": "projects_git_authorization_grant_id_connection_grants_id_fk", + "tableFrom": "projects", + "tableTo": "connection_grants", + "columnsFrom": [ + "git_authorization_grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -55942,6 +55967,12 @@ "notNull": true, "default": "''" }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "owner_note": { "name": "owner_note", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 97d684cc9ce..b6d2fe1f6bd 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2647,6 +2647,13 @@ "when": 1793049353756, "tag": "0379_white_giant_girl", "breakpoints": true + }, + { + "idx": 380, + "version": "7", + "when": 1793060791724, + "tag": "0380_broken_aqueduct", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_github.ts b/packages/db/src/schema/chat_github.ts index 2b2fddae486..ebd1928ccaf 100644 --- a/packages/db/src/schema/chat_github.ts +++ b/packages/db/src/schema/chat_github.ts @@ -75,6 +75,8 @@ export const chatGitHubRegistrations = pgTable( manifestClaimId?: string; identityLeaseId?: string; identityLeaseExpiresAt?: string; + browserSessionId?: string; + identitySessionId?: string; identityStateHash?: string; identityExpiresAt?: string; identityRedemptionId?: string; diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts index 5f356e7a8d5..747828734e4 100644 --- a/packages/db/src/schema/tool_access.ts +++ b/packages/db/src/schema/tool_access.ts @@ -564,6 +564,8 @@ export const toolMcpGatewayTokens = pgTable( subjectType: text("subject_type").$type().notNull().default("gateway_client"), subjectId: text("subject_id"), clientLabel: text("client_label").notNull().default(""), + // Null distinguishes board/legacy clients from delegated agent clients. + responsibleUserId: text("responsible_user_id"), ownerNote: text("owner_note").notNull().default(""), allowedActions: jsonb("allowed_actions").$type().notNull().default(["tools/list", "tools/call"]), expiresAt: timestamp("expires_at", { withTimezone: true }), diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index fbe3f2753ad..c65d9d1dd40 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 63, + "agentProfileVersion": 64, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:1412842a36a7211d84b6b98a3d1f69e3388bc4814e5825da890a774ae0e3460e", + "commandDigest": "sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index ed82e55fea9..b804b781bc6 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:1412842a36a7211d84b6b98a3d1f69e3388bc4814e5825da890a774ae0e3460e", + "sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 770540a02c5..90033995a3f 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v63-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v64-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 63 }; +const declaration = { ...historical.declaration, agentProfileVersion: 64 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts b/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts index 3d4e22a01a5..950787f5b91 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context-prompts.ts @@ -1,4 +1,4 @@ -export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v7"; +export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v8"; export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. " + @@ -6,7 +6,7 @@ export const PAPERCLIP_EXECUTION_PROMPT = "When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. " + "When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. " + "When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. " + - "For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. " + + "For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. For user-requested setup, use search_api and call_api to perform authorized connection, chat-channel, and AI-account configuration. Standard agents inherit connection management bounded by their responsible user and explicit restrictions. Reuse saved drafts. Hand off only missing decisions, human authentication, consent, or credential entry. Suggest a relevant connection when it helps. Do not silently relax an Off/Ask first policy after a failed call, approve your own action, or change your own principal permissions. Report saved, authenticated, usable, and verified separately. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. " + "For a deferred check you own, call set_task_monitor with a future nextCheckAt and notes. Confirm the persisted schedule on the current task, then call paperclip_finish with reportedWorkDisposition yielded and continuation.kind monitor. End the turn; the one-shot monitor wakes you with issue_monitor_due. Re-arm explicitly only if another check is needed. Do not claim a monitor exists without its receipt. " + "Finish exactly once with `paperclip_finish` or `paperclip_block`."; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 2aa9af9391b..56c0f1308fe 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,9 +87,24 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(63); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(64); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:1412842a36a7211d84b6b98a3d1f69e3388bc4814e5825da890a774ae0e3460e"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a"); + }); + + it("preserves published v63 while binding delegated connection guidance in v64", async () => { + const priorBytes = readFileSync(new URL("../../../test/fixtures/copilot-profile-v63-identity.json", import.meta.url)); + expect(createHash("sha256").update(priorBytes).digest("hex")).toBe("4adb05bd642f0222a3bcc85cfefbeb2988de439f58125012e1c575caedd90798"); + const prior = JSON.parse(priorBytes.toString("utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v64-identity.json", import.meta.url), "utf8")); + expect(prior.commandDigest).toBe("sha256:1412842a36a7211d84b6b98a3d1f69e3388bc4814e5825da890a774ae0e3460e"); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ + "agentProfileVersion", "nativeRuntimePromptsSourceSha256", "profileCompatibilitySourceSha256", + ]); + expect(Object.keys(current.declaration).sort()).toEqual(Object.keys(prior.declaration).sort()); + expect(isSupportedAcpxProfileVersion("copilot", 63)).toBe(true); + await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, + agentProfileVersion: 63, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned"); }); it("preserves both published v62 identities while binding their merged source in v63", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 4cf0892c5c5..508f5a24c1d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 63, + "agentProfileVersion": 64, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:1412842a36a7211d84b6b98a3d1f69e3388bc4814e5825da890a774ae0e3460e", + "commandDigest": "sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 8f5fd0a22d9..2eb8b782321 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v64-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v64-identity.json new file mode 100644 index 00000000000..6b9f3a737ab --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v64-identity.json @@ -0,0 +1,85 @@ +{ + "commandDigest": "sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 64, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "17361d1782c0dcebb2b7972635f45c890f2a4081f8bc0ef2d2276550e0b91629", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "f49398b682a5623812f5d3c33d6bb9a749008e47cc0ed969072041615f66df59", + "semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9", + "semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce", + "nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842", + "nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523", + "nativeBackendSourceSha256": "e7b12d4418c7a71f5f2474b046697d3009eae3613b284bec381df5e9965a1c37", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "9173ebccf09cb56a781d2bf5c7eb27fdafb13d3450d99342c6aff86b42d6b43a", + "providerLaunchSourceSha256": "fa8db1c42c378898287c141d8cea9069166981e369c9b9c51422cb6177de5a2c", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "9be35e006ffffcca48205f9e53548dd453ec6a8cbcf0aa4dd735194e9619dfc7", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f", + "nativeExecutionSourceSha256": "3a30d52bffad68c7f02349193be84761050eb99039fc0556c2b449891a36e918", + "profileCompatibilitySourceSha256": "2f875a2f39f4e99a3d03ffce9f325ce2461c51c6b8cb70218ce89ec008eaa5b8", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "7586d44b47c83395498dc8806287cd3538ef58177ba7c5949f5766ed105a0161", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "c9cc0fd516b3df92a6655f56ee9bef37615a889c371e2e137d8ac5c1176de811", + "runtimeHostSourceSha256": "94a73749267f5a80b5df19413a65b4b9263c00a9bf7e98fda00f00b8f9fd32ec", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/shared/src/connection-intent-guidance.test.ts b/packages/shared/src/connection-intent-guidance.test.ts index 59ef1d0aa8f..76047c03750 100644 --- a/packages/shared/src/connection-intent-guidance.test.ts +++ b/packages/shared/src/connection-intent-guidance.test.ts @@ -3,13 +3,13 @@ import { CONNECTION_INTENT_AGENT_GUIDANCE, CONNECTION_REQUEST_TOOL_DESCRIPTION, describe("connection result guidance", () => { it("delegates route decisions to trusted tool output instead of a provider decision tree", () => { expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("follow the returned `instruction`"); - expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded user choices"); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded declines and provider choices"); expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/Composio|Arcade|Executor|Zapier/); expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/https?:\/\/|bearer/); }); it("retains waiting, credential, and arbitrary URL boundaries", () => { expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("yield without retrying or polling"); - expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never invent access or ask for credentials in comments"); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never ask for credentials in comments"); expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("arbitrary MCP URLs"); }); it("describes exact questions and saved selection proof", () => { diff --git a/packages/shared/src/connection-intent-guidance.ts b/packages/shared/src/connection-intent-guidance.ts index 8c63bb36c28..7178d4afddd 100644 --- a/packages/shared/src/connection-intent-guidance.ts +++ b/packages/shared/src/connection-intent-guidance.ts @@ -8,13 +8,15 @@ export const CONNECTION_INTENT_AGENT_GUIDANCE = [ "Connection tools:", "- When the user asks to connect a service, call `connections_search` before any service tool, even if already installed. Also search when a task needs a service and usable access is uncertain. Search by its name or capability and follow the returned `instruction`.", - "- Use the returned service identifiers and connection tools for setup. Respect recorded user choices; never invent access or ask for credentials in comments.", - "- If installed tools are Off or the connection is not enabled for this agent, call `connection_request` with the saved connectionId and required toolNames. It creates a scoped human access card; never substitute a generic permission question or change permissions yourself.", + "- Distinguish tool, chat/channel, and AI account purposes. Use `search_api` and `call_api` for authorized connection configuration requested by the user. Standard agents can manage connections under their responsible user's authority; inspect the returned capabilities and respect explicit restrictions. Reuse saved accounts and setup drafts.", + "- Suggest a relevant connection when it would help the task and usable access is missing. Respect recorded declines and provider choices. Ask only for missing decisions, consent, credentials, or authority; never ask for credentials in comments. Hand off the exact required human action at the saved setup link, then inspect status and continue through the API.", + "- If a tool call is blocked by Off/Ask first or missing installation access, use `connection_request` with the saved connectionId and required toolNames. Do not silently relax policy to work around a failed call. You may perform an access-policy change explicitly requested by the user when authorized; never approve your own governed action or change your own principal permissions.", + "- Report saved, authenticated, usable, and verified as separate states. A saved connection is not proof of successful authentication or a verified provider action.", "- When waiting for user action, finish independent work, then yield without retrying or polling. On continuation, follow the recorded outcome and use the installed connection.", "- Do not use connection tools for arbitrary MCP URLs or unrelated work.", ].join("\n"); -export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or a natural-language description (up to 4000 characters). Results tolerate extra words, split names, and small typos. Choose the relevant match using its description and method purpose. Follow the returned instruction to present an inline setup card with connection_request or share the method's setupPath. Also discovers verified external aggregator apps. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only."; +export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or natural-language description (up to 4000 characters). Choose the relevant method and inspect its management capabilities. For user-requested configuration, discover and call the existing REST operation with search_api/call_api; reuse saved drafts and hand off only required human authentication or consent. Use connection_request for missing access or required user input. Also discovers verified external aggregator apps. Use first when asked to connect a service or usable access is uncertain; respect the returned instruction, recorded declines, and exact providerQuestion. Do not use for arbitrary MCP URLs. Search is read-only."; export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search. For an existing connection with missing agent access, pass connectionId and the exact required toolNames to create an embedded Grant access card; only the human can approve it. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments."; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6dfa3ec841d..5d760d874c8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -70,6 +70,7 @@ export type { ConnectionSearchResultItem, ConnectionsSearchResult, ConnectionRequestResult, + ConnectionRequiredUserAction, ConnectionIntentSetupOptions, ConnectionIntentSetupConnection, CompleteConnectionIntentInput, diff --git a/packages/shared/src/types/connection-intent.ts b/packages/shared/src/types/connection-intent.ts index 652d0b30358..fe314b98f4c 100644 --- a/packages/shared/src/types/connection-intent.ts +++ b/packages/shared/src/types/connection-intent.ts @@ -3,6 +3,17 @@ import type { ToolConnection } from "./tool-access.js"; import type { AskUserQuestionsQuestion, PaperclipQuestionSetPayload } from "./issue.js"; import type { RemoteMcpConnectorId } from "../remote-mcp-connectors.js"; +/** A saved setup's next human step. This is not an authorization grant. */ +export interface ConnectionRequiredUserAction { + kind: "authenticate" | "consent" | "enter_credentials" | "confirm_identity"; + userId: string; + reason: string; + resource: { type: "connection" | "chat_endpoint" | "ai_login"; id: string; companyId: string }; + setupPath: string; + expiresAt?: string; + continuation?: string; +} + export type ConnectionAvailabilityState = | "ready" | "needs_user_action" @@ -32,6 +43,15 @@ export interface ConnectionSearchResultItem { purpose?: "tool" | "channel" | "ai"; /** Company-scoped setup destination for methods with a separate setup flow. */ setupPath?: string; + management?: { + allowed: boolean; + reason: string; + operation: string; + canUse: boolean; + requiresApproval?: boolean; + requiresHumanAuthentication: boolean; + nextApiOperation: string; + }; }>; state: ConnectionAvailabilityState; connectionId: string | null; @@ -62,6 +82,7 @@ export interface ConnectionRequestResult { export type ConnectionIntentSetupConnection = Pick; export interface ConnectionIntentSetupOptions { + chatSetup?: { path: string; endpointId: string; connectionId: string }; /** Resume this request's saved AgentMail account after a partial setup. */ emailSetup?: { credentialConnectionId: string | null; readyConnectionId: string | null }; canGrantAccess?: boolean; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 13ee15a438d..9da3eff2ee2 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -12,6 +12,7 @@ export type { ConnectionSearchResultItem, ConnectionsSearchResult, ConnectionRequestResult, + ConnectionRequiredUserAction, ConnectionIntentSetupOptions, ConnectionIntentSetupConnection, CompleteConnectionIntentInput, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 68a03628e82..00bfb4fab21 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1451,6 +1451,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; action?: "enter_credentials" | "rotate_credentials" | "confirm_identity"; credentialRotation?: { previousId: string | null }; credentialEntry?: { previousGenerations: Record; requireAll: boolean } }; version: 1; /** Server-authored consent to a fixed set of tools on an existing connection. */ accessRequest?: { diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index cfd1c1bf8e3..b85ef4b83fd 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -302,6 +302,8 @@ export interface ConnectionGrantCapabilities { * not "render disabled". */ export interface ToolConnectionCapabilities { + management?: { allowed: boolean; reason: string; operation: string }; + usage?: { allowed: boolean; reason: string; operation: string; requiresApproval?: boolean; allowedCount?: number; askFirstCount?: number }; canConfigure: boolean; canCreateOrganizationGrant: boolean; canSetCompanyInstall: boolean; @@ -745,6 +747,8 @@ export interface ToolMcpGatewayToken { revokedAt: Date | string | null; createdByAgentId: string | null; createdByUserId: string | null; + /** Agent client delegation. Null with an issuing agent identifies an unbound legacy client. */ + responsibleUserId?: string | null; createdAt: Date | string; updatedAt: Date | string; } @@ -1234,7 +1238,8 @@ export interface ToolOAuthStartResult { connectionId: string; provider: string; authorizationUrl: string; - expiresAt: string; + /** Null until the addressed human starts provider authorization from a saved setup. */ + expiresAt: string | null; /** * Opaque Paperclip Cloud authorization handoff. The board submits this only * to its fixed same-origin Cloud endpoint; it is never treated as a URL. @@ -1574,6 +1579,7 @@ export interface ToolAccessDecisionInput { }; runContext?: { heartbeatRunId?: string | null; + originatingAgentId?: string | null; issueId?: string | null; projectId?: string | null; routineId?: string | null; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 9e42c7b1187..98f88a5af22 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1170,6 +1170,11 @@ const connectionIntentBrandAssetSchema = z export const connectionIntentPayloadSchema = z .object({ + savedSetup: z.object({ endpointId: z.string().guid(), connectionId: z.string().guid(), action: z.enum(["enter_credentials", "rotate_credentials", "confirm_identity"]).optional(), credentialRotation: z.object({ previousId: z.string().guid().nullable() }).strict().optional(), credentialEntry: z.object({ + previousGenerations: z.record(z.string().refine(field => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(field)), z.string().uuid().nullable()).refine(value => Object.keys(value).length > 0 && Object.keys(value).length <= 5), + requireAll: z.boolean(), + }).strict().optional() }).strict() + .refine(value => value.action !== "rotate_credentials" || !!value.credentialRotation, "Rotation requests need a completion boundary").optional(), accessRequest: z.object({ connectionId: z.string().guid(), connectionName: z.string().trim().min(1).max(160), diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index 88279b91f2e..cba65a86d5a 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -23,6 +23,7 @@ import { aiConnectionRouterService } from "../services/ai-connection-router.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { toolAccessService } from "../services/tool-access.js"; import { waitForPendingRunFailureReports } from "../services/run-failure-report.js"; +import { accessService } from "../services/access.js"; import { configureAgentLifecycle } from "../services/agent-lifecycle.js"; const captureRunFailure = vi.hoisted(() => vi.fn()); @@ -69,6 +70,8 @@ async function fixture(provider: "anthropic" | "openai", method: "api_key" | "su await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }); await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:create" }); await db.insert(agents).values({ id: agentId, companyId, name: "Manager", role: "ceo", adapterType, runtimeConfig: { aiConnection: binding } }); + await accessService(db).ensureMembership(companyId, "agent", agentId, "member", "active"); + await db.insert(principalPermissionGrants).values(["tools:manage_profiles", "agents:configure"].map(permissionKey => ({ companyId, principalType: "agent" as const, principalId: agentId, permissionKey }))); const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", responsibleUserId: userId }).returning(); const app = express(); app.use(express.json()); diff --git a/server/src/__tests__/ai-connection-router.test.ts b/server/src/__tests__/ai-connection-router.test.ts index bb5686170bc..329feb58dbc 100644 --- a/server/src/__tests__/ai-connection-router.test.ts +++ b/server/src/__tests__/ai-connection-router.test.ts @@ -115,6 +115,7 @@ describe("durable, authorized connection routing", () => { expect((await db.select().from(toolConnections).where(eq(toolConnections.id, members[0]!.binding.connectionId)))[0]?.status).toBe("active"); }); it("requires company connection management authority on pool configuration routes", async () => { + await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice"))); const app = express(); app.use(express.json()); let actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] }; app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); }); app.use("/api", aiConnectionRoutes(db)); app.use(errorHandler); @@ -122,6 +123,7 @@ describe("durable, authorized connection routing", () => { expect((await request(app).get(url)).status).toBe(200); expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connection-pools`)).status).toBe(403); expect((await request(app).delete(`/api/companies/${otherCompanyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice"))); actor = { ...actor, memberships: [{ companyId, status: "active", membershipRole: "viewer" }] }; expect((await request(app).get(url)).status).toBe(403); expect((await request(app).post(url).send({ pluginKey, config: pool })).status).toBe(403); @@ -130,10 +132,12 @@ describe("durable, authorized connection routing", () => { actor = { type: "agent", agentId, companyId, source: "agent_jwt" } as unknown as typeof actor; expect((await request(app).get(url)).status).toBe(403); expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(403); + await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice"))); actor = { type: "board", userId: "alice", source: "session", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] }; expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409); expect((await request(app).delete(`${url}/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(200); expect((await request(app).get(url)).body.some((entry: { id: string }) => entry.id === pool.id)).toBe(false); + await db.update(companyMemberships).set({ membershipRole: "member" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, "alice"))); }); it("deletes pools without erasing pins, cursors, or admitted recovery evidence", async () => { const pool = await makePool(); const selected = await resolve(pool.id, "retained"); diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 804405037a2..05bca7807a0 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -10074,6 +10074,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: [ @@ -10560,7 +10561,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", ); } @@ -58322,7 +58323,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const before = await db .select({ id: chatActions.id }) .from(chatActions) - .where(eq(chatActions.endpointId, context.endpoint.id)); + .where(eq(chatActions.endpointId, context.endpoint.id)) + .orderBy(chatActions.id); if (linkedPrincipalId) { await db .update(chatIdentityLinks) @@ -58404,7 +58406,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { db .select({ id: chatActions.id }) .from(chatActions) - .where(eq(chatActions.endpointId, context.endpoint.id)), + .where(eq(chatActions.endpointId, context.endpoint.id)) + .orderBy(chatActions.id), ).resolves.toEqual(before); } finally { await db diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts new file mode 100644 index 00000000000..4aec44db9c1 --- /dev/null +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -0,0 +1,1560 @@ +import { googleWorkspaceSelectedScopes, defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared"; +import { randomUUID, generateKeyPairSync } from "node:crypto"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import { and, eq } from "drizzle-orm"; +import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; +import { activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } 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 * as remoteEndpointGuard from "../services/remote-http-endpoint-guard.js"; +import * as remoteHttpFetch from "../services/remote-http-fetch.js"; +import * as runIdentity from "../services/run-identity.js"; +import * as connectionManagementModule from "../services/connection-management.js"; +import * as localBrowserLogin from "../services/local-ai-browser-login.js"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; +import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; +import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; +import { configureAgentLifecycle } from "../services/agent-lifecycle.js"; +import { agentService } from "../services/agents.js"; +import { accessService } from "../services/access.js"; +import { secretService } from "../services/secrets.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; + +vi.mock("../services/agent-lifecycle-driver.js", () => ({ + createLifecycleDriver: () => ({ requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" }), +})); + +// All requests traverse the assembled server and production bearer middleware. +// No req.actor injection or special permission grants establish access. +describe("connection management with production default agents", () => { + let server: Awaited>; + let lifecycle: ReturnType; + let provider: ((url: string, init?: RequestInit) => Promise) | null = null; + let providerFetch: ReturnType; + beforeAll(async () => { + vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID()); + vi.stubEnv("PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE", "shadow"); + vi.stubEnv("PAPERCLIP_AUTH_RATE_LIMIT_ENABLED", "false"); + const realFetch = globalThis.fetch; + providerFetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const url = String(input); + if (new URL(url).hostname === "127.0.0.1") return realFetch(input, init); + if (provider) return provider(url, init); + throw new Error("Unexpected external request in provider fixture"); + }); + server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true, databaseUrl: process.env.PAPERCLIP_CONNECTION_MANAGEMENT_TEST_DATABASE_URL }); + 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); + await vi.waitFor(async () => expect((await agentService(server.db).getById(agent.id))?.status).toBe("idle"), { timeout: 10_000 }); + 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); + expect(await f.request("GET", `/companies/${f.companyId}/tools/stdio-templates`)).toMatchObject({ status: 403 }); + 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("checks effective-profile connection access once for hundreds of tools on the same account", async () => { + const f = await fixture(); + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Large catalog", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } }); + expect(connection).toMatchObject({ status: 201 }); + await server.db.insert(toolCatalogEntries).values(Array.from({ length: 200 }, (_, index) => ({ companyId: f.companyId, applicationId: connection.body.applicationId, connectionId: connection.body.id, name: `tool_${index}`, toolName: `tool_${index}`, versionHash: `fixture_${index}` }))); + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "large-catalog", name: "Large catalog", entries: [{ selectorType: "connection", connectionId: connection.body.id }] }); + expect(profile).toMatchObject({ status: 201 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${profile.body.id}/bind`, { targetType: "agent", targetId: f.agentId })).toMatchObject({ status: 201 }); + expect(await f.request("PUT", `/tool-connections/${connection.body.id}/installs`, { installs: [{ targetType: "agent", targetId: f.agentId }] })).toMatchObject({ status: 200 }); + const module = await import("../services/connection-management.js"); + const original = connectionManagementService; + let connectionChecks = 0; + const factory = vi.spyOn(module, "connectionManagementService").mockImplementation((...args) => { + const service = original(...args); + return { ...service, capability: async (...input: Parameters) => { + if (input[2].connectionId === connection.body.id) connectionChecks++; + return service.capability(...input); + } }; + }); + try { + const effective = await f.request("GET", `/companies/${f.companyId}/tools/profiles/effective/agents/${f.agentId}`); + expect(effective, JSON.stringify(effective)).toMatchObject({ status: 200 }); + expect(effective.body.allowedTools).toHaveLength(200); + expect(effective.body.installedConnections).toHaveLength(1); + expect(connectionChecks).toBe(1); + } finally { factory.mockRestore(); } + }); + + it("checks every saved profile selector and effective projection against personal account visibility", async () => { + const f = await fixture(); + const first = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Owned profile account", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } }); + expect(first).toMatchObject({ status: 201 }); + const second = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { applicationId: first.body.applicationId, name: "Private profile account", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } }); + expect(second, JSON.stringify(second)).toMatchObject({ status: 201, body: { applicationId: first.body.applicationId } }); + const [known] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: first.body.applicationId, connectionId: first.body.id, name: "known", toolName: "known", versionHash: "known" }).returning(); + const profileIds: string[] = []; + const selectors = [ + { selectorType: "application", applicationId: first.body.applicationId }, + { selectorType: "catalog_entry", catalogEntryId: known.id }, + { selectorType: "tool_name", toolName: "private_tool" }, + { selectorType: "risk_level", riskLevel: "read" }, + ]; + for (const [index, entry] of selectors.entries()) { + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: `privacy-${index}`, name: `Privacy ${index}`, entries: [entry] }); + expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 }); + profileIds.push(profile.body.id); + } + const all = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "privacy-all", name: "All tools", defaultAction: "allow", entries: [] }); + expect(all).toMatchObject({ status: 201 }); + profileIds.push(all.body.id); + expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${profileIds[0]}/bind`, { targetType: "agent", targetId: f.agentId })).toMatchObject({ status: 201 }); + await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: first.body.applicationId, connectionId: second.body.id, name: "private_tool", toolName: "private_tool", description: "Private tool metadata", versionHash: "private", firstSeenAt: new Date(Date.now() + 60_000) }); + expect(await f.request("GET", `/tool-profiles/${profileIds[1]}/new-tools`)).toMatchObject({ status: 200, body: { tools: [expect.objectContaining({ connectionName: "Private profile account", description: "Private tool metadata" })] } }); + const effectivePath = `/companies/${f.companyId}/tools/profiles/effective/agents/${f.agentId}`; + expect((await f.request("GET", effectivePath)).body.allowedTools.some((tool: { toolName: string }) => tool.toolName === "private_tool")).toBe(true); + expect(await f.request("PUT", `/tool-connections/${second.body.id}/installs`, { installs: [{ targetType: "agent", targetId: f.agentId }] })).toMatchObject({ status: 200 }); + await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, second.body.id)); + for (const token of [f.key.token, f.jwt]) { + expect(await f.request("GET", `/tool-profiles/${profileIds[1]}/new-tools`, undefined, token)).toMatchObject({ status: 403 }); + for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools`, undefined, token)).toMatchObject({ status: 403 }); + expect((await f.request("GET", `/companies/${f.companyId}/tools/profiles`, undefined, token)).body.profiles).toEqual([]); + expect(await f.request("GET", effectivePath, undefined, token)).toMatchObject({ status: 403 }); + } + for (const [index, entry] of selectors.entries()) expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: `private-attempt-${index}`, name: `Private attempt ${index}`, entries: [entry] })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "private-attempt-all", name: "Private all", defaultAction: "allow", entries: [] })).toMatchObject({ status: 403 }); + await server.db.delete(toolProfileBindings).where(and(eq(toolProfileBindings.companyId, f.companyId), eq(toolProfileBindings.targetType, "agent"), eq(toolProfileBindings.targetId, f.agentId))); + expect(await f.request("GET", effectivePath)).toMatchObject({ status: 403 }); + await server.db.update(toolConnections).set({ createdByUserId: f.userId }).where(eq(toolConnections.id, second.body.id)); + expect(await f.request("GET", effectivePath)).toMatchObject({ status: 200 }); + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [first.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 403 }); + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [first.body.id, second.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 200 }); + }); + + it("rechecks authority after remote endpoint validation before committing a connection update", async () => { + const f = await fixture(); + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Before validation", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + expect(created).toMatchObject({ status: 201 }); + const original = remoteEndpointGuard.assertPublicRemoteHttpEndpoint; + const validation = vi.spyOn(remoteEndpointGuard, "assertPublicRemoteHttpEndpoint").mockImplementation(async (...args) => { + const result = await original(...args); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + return result; + }); + try { + expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "After validation" })).toMatchObject({ status: 403 }); + expect(validation).toHaveBeenCalled(); + expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id)))[0].name).toBe("Before validation"); + } finally { validation.mockRestore(); } + }); + + it("reaches sandbox device-login management as the responsible user and protects another owner's saved session", async () => { + const f = await fixture(); + registerServerAdapter({ ...getServerAdapter("paperclip_runner"), type: "codex_local", loginCapability: { + panelMode: "displayed_code", timeoutPolicy: "caller_bounded", getCommand: () => "codex login --device-auth", parsePrompt: () => null, + } }); + try { + const [environment] = await server.db.insert(environments).values({ name: `Login fixture ${f.agentId}`, driver: "sandbox" }).returning(); + const [session] = await server.db.insert(adapterAuthSessions).values({ companyId: f.companyId, environmentId: environment.id, adapterType: "codex_local", startedByUserId: f.userId, + publicSessionId: randomUUID(), status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000), aiConnection: { ownership: "personal", name: "Saved login", allAgents: false, agentIds: [] } }).returning(); + const path = `/companies/${f.companyId}/adapters/codex_local/login-sessions`; + expect(await f.request("GET", `${path}/active`)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } }); + expect(await f.request("GET", `${path}/${session.publicSessionId}`, undefined, f.jwt)).toMatchObject({ status: 200, body: { sessionId: session.publicSessionId } }); + expect(await f.request("POST", path, {})).toMatchObject({ status: 400 }); + expect(await f.request("POST", `${path}/${session.publicSessionId}/cancel`, {})).toMatchObject({ status: 200 }); + await server.db.update(adapterAuthSessions).set({ startedByUserId: randomUUID() }).where(eq(adapterAuthSessions.id, session.id)); + expect(await f.request("GET", `${path}/${session.publicSessionId}`)).toMatchObject({ status: 404 }); + expect(await f.request("POST", `${path}/${session.publicSessionId}/cancel`, {})).toMatchObject({ status: 404 }); + } finally { unregisterServerAdapter("codex_local"); } + }); + + it("checks finish access, all-agent sharing, and retained targets against both management grants", async () => { + const f = await fixture(); + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Finish authority", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + const path = `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`; + const body = { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [f.agentId] } }; + expect(await f.request("POST", path, body)).toMatchObject({ status: 200 }); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + for (const access of [body.access, { agentIds: [] }, "all_agents"]) expect(await f.request("POST", path, { ...body, access })).toMatchObject({ status: 403 }); + await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tools:manage_profiles", scope: { targetAgentIds: [f.agentId] } }); + expect(await f.request("POST", path, { ...body, access: "all_agents" })).toMatchObject({ status: 403 }); + const [other] = await server.db.select({ id: agents.id }).from(agents).where(and(eq(agents.companyId, f.companyId), eq(agents.name, "Eval agent"))); + const targets = await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId)); + const otherId = other?.id ?? targets.find(agent => agent.id !== f.agentId)!.id; + expect(await f.request("POST", path, { ...body, access: { agentIds: [otherId] } })).toMatchObject({ status: 403 }); + await server.db.update(principalPermissionGrants).set({ scope: null }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + expect(await f.request("POST", path, { ...body, access: { agentIds: [otherId] } })).toMatchObject({ status: 403 }); + const bindings = await server.db.select().from(toolProfileBindings).where(eq(toolProfileBindings.companyId, f.companyId)); + expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]); + }); + + it.each(["none", "other-agent", "other-agent-no-access", "other-agent-allow-policy", "other-agent-explicit-grant", "other-agent-caller-ask", "other-agent-caller-rate", "other-agent-shared-rate", "other-agent-credential", "other-agent-credential-none", "other-agent-caller-revoked", "private-status", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval", "authorized-policy-edit"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => { + const f = await fixture(); + let targetAgentId = f.agentId; + if (revocation.startsWith("other-agent")) { + const cookie = await server.humanSession(f.userId); + const created = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }) }); + const target = await created.json(); + expect(created.status, JSON.stringify(target)).toBe(201); + targetAgentId = target.id; + await lifecycle.process(targetAgentId); + await vi.waitFor(async () => expect((await agentService(server.db).getById(targetAgentId))?.status).toBe("idle")); + } + const allowed = revocation === "none" || revocation === "other-agent" || revocation === "other-agent-caller-ask"; + let calls = 0; + const remote = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const request = JSON.parse(Buffer.concat(chunks).toString() || "{}"); + let result: unknown; + if (request.method === "initialize") result = { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "Fixture", version: "1" } }; + else if (request.method === "tools/call") { calls++; result = { content: [{ type: "text", text: "Fixture result" }] }; } + else { res.writeHead(req.method === "DELETE" ? 204 : request.method === "notifications/initialized" ? 202 : 405); res.end(); return; } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: request.id, result })); + }); + await new Promise(resolve => remote.listen(0, "127.0.0.1", resolve)); + const address = remote.address(); + if (!address || typeof address === "string") throw new Error("Fixture did not listen"); + const mcpUrl = `http://127.0.0.1:${address.port}/mcp`; + const freezeRateClock = ["other-agent-caller-rate", "other-agent-shared-rate", "rate-limit", "agent-rate-approval"].includes(revocation); + if (freezeRateClock) vi.useFakeTimers({ toFake: ["Date"] }); + try { + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Governed test", transport: "mcp_remote", authKind: "none", transportConfig: { url: mcpUrl } }); + await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, created.body.id)); + const [entry] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: created.body.applicationId, connectionId: created.body.id, + entryKind: "tool", name: "Fixture probe", toolName: "fixture_probe", inputSchema: { type: "object", properties: {} }, riskLevel: "read", isReadOnly: true, status: "active", versionHash: "fixture-v1" }).returning(); + expect(await f.request("POST", `/companies/${f.companyId}/tools/apps/${created.body.id}/finish`, { enabledCatalogEntryIds: [entry.id], access: { agentIds: revocation.startsWith("other-agent") && !["other-agent-no-access", "other-agent-allow-policy", "other-agent-explicit-grant"].includes(revocation) ? [f.agentId, targetAgentId] : [targetAgentId] } })).toMatchObject({ status: 200 }); + const path = `/tool-connections/${created.body.id}/test-calls`; + const body = { agentId: targetAgentId, runContext: { heartbeatRunId: randomUUID(), issueId: randomUUID(), projectId: randomUUID() }, toolName: "fixture_probe", parameters: {} }; + if (revocation === "other-agent-no-access") { + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller cannot use this tool", policyType: "block", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 }); + for (const token of [f.key.token, f.jwt]) expect(await f.request("POST", path, body, token)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "deny_policy_block" } } }); + expect(calls).toBe(0); + return; + } + if (revocation === "other-agent-allow-policy" || revocation === "other-agent-explicit-grant") { + if (revocation === "other-agent-allow-policy") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller authorized by policy", policyType: "allow", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 }); + else await server.db.update(principalPermissionGrants).set({ scope: { connectionId: created.body.id } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:use"))); + const result = await f.request("POST", path, body, f.jwt); + expect(result, JSON.stringify(result)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } }); + expect(calls).toBe(1); + return; + } + if (revocation === "other-agent-credential" || revocation === "other-agent-credential-none") { + await server.db.update(toolConnections).set({ authKind: revocation === "other-agent-credential-none" ? "none" : "api_key", credentialPolicy: "per_agent" }).where(eq(toolConnections.id, created.body.id)); + await server.db.insert(connectionGrants).values([f.agentId, targetAgentId].map(subjectAgentId => ({ companyId: f.companyId, connectionId: created.body.id, kind: "agent" as const, subjectAgentId, status: "active" }))); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 403, body: { reasonCode: "test_credential_identity_mismatch" } }); + expect(calls).toBe(0); + return; + } + if (revocation === "other-agent-caller-rate" || revocation === "other-agent-shared-rate") { + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller rate", policyType: "rate_limit", selectors: { connectionId: created.body.id, ...(revocation === "other-agent-caller-rate" ? { agentId: f.agentId } : {}) }, config: { limit: 1, windowSeconds: 60, keyBy: revocation === "other-agent-caller-rate" ? ["agent", "tool"] : ["company", "tool"] } }); + const admitted = await f.request("POST", path, body, f.jwt); + expect(admitted, JSON.stringify(admitted)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } }); + expect(calls).toBe(1); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } }); + expect(calls).toBe(1); + return; + } + for (const selectors of [{ connectionId: created.body.id, projectId: f.projectId }, { connectionId: created.body.id, issueId: f.issueId }]) { + const [block] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test block", policyType: "block", selectors, priority: 1 }).returning(); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off" } }); + expect(calls).toBe(0); + await server.db.delete(toolPolicies).where(eq(toolPolicies.id, block.id)); + } + if (revocation === "rate-limit") { + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One test per minute", policyType: "rate_limit", selectors: { connectionId: created.body.id }, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } }); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } }); + expect(calls).toBe(1); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } }); + expect(calls).toBe(1); + return; + } + if (revocation === "agent-rate-approval") { + await server.db.insert(toolPolicies).values([ + { companyId: f.companyId, name: "One admitted call", policyType: "rate_limit", selectors: { connectionId: created.body.id }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } }, + { companyId: f.companyId, name: "Approve admitted call", policyType: "require_approval", selectors: { connectionId: created.body.id }, priority: 1 }, + ]); + const session = await f.request("POST", "/tool-gateway/sessions", {}, f.jwt); + expect(session, JSON.stringify(session)).toMatchObject({ status: 201 }); + const gatewayRequest = async (path: string, parameters?: unknown) => { + const response = await fetch(server.apiUrl + "/api" + path, { method: parameters === undefined ? "GET" : "POST", headers: { "x-paperclip-tool-gateway-token": session.body.token, "Content-Type": "application/json" }, ...(parameters === undefined ? {} : { body: JSON.stringify(parameters) }) }); + return { status: response.status, body: await response.json() }; + }; + const listed = await gatewayRequest("/tool-gateway/tools"); + expect(listed).toMatchObject({ status: 200 }); + const tool = listed.body.find((item: { catalogEntryId: string }) => item.catalogEntryId === entry.id); + expect(tool, JSON.stringify(listed.body)).toBeTruthy(); + const requested = await gatewayRequest("/tool-gateway/tools/call", { tool: tool.name, parameters: {}, idempotencyKey: "rate-approval" }); + expect(requested, JSON.stringify(requested)).toMatchObject({ status: 409, body: { reasonCode: "approval_required" } }); + expect(calls).toBe(0); + expect(await f.request("POST", `/tool-gateway/action-requests/${requested.body.actionRequestId}/approve`, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); + const cookie = await server.humanSession(f.userId); + const response = await fetch(`${server.apiUrl}/api/tool-gateway/action-requests/${requested.body.actionRequestId}/approve`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) }); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + expect((await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, requested.body.invocationId)))[0].status).toBe("succeeded"); + expect(calls).toBe(1); + expect(await gatewayRequest("/tool-gateway/tools/call", { tool: tool.name, parameters: { anotherCall: true }, idempotencyKey: "next-rate-approval" })).toMatchObject({ status: 429, body: { reasonCode: "rate_limited" } }); + expect(calls).toBe(1); + return; + } + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test approval", policyType: "require_approval", selectors: { connectionId: created.body.id, issueId: f.issueId, ...(revocation === "other-agent-caller-ask" ? { agentId: f.agentId } : {}) }, priority: 1 }); + const pending = await f.request("POST", path, body, f.jwt); + expect(pending, JSON.stringify(pending)).toMatchObject({ status: 200, body: { decision: "ask_first" } }); + const [saved] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, pending.body.invocationId)); + expect(saved).toMatchObject({ runId: f.runId, issueId: f.issueId, actorType: "agent", actorId: f.agentId, agentId: targetAgentId }); + expect(calls).toBe(0); + if (revocation === "private-status") { + const statusPath = `${path}/${pending.body.actionRequestId}`; + expect(await f.request("GET", statusPath)).toMatchObject({ status: 200 }); + const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private test task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + await server.db.update(toolInvocations).set({ issueId: hidden.id }).where(eq(toolInvocations.id, saved.id)); + for (const token of [f.key.token, f.jwt]) expect(await f.request("GET", statusPath, undefined, token)).toMatchObject({ status: 403 }); + expect(calls).toBe(0); + return; + } + const approvalPath = `/tool-gateway/action-requests/${pending.body.actionRequestId}/approve`; + expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); + if (revocation === "authorized-policy-edit") { + const policiesPath = `/companies/${f.companyId}/tools/policies`; + const policy = await f.request("POST", policiesPath, { name: "Requested self-targeted access", policyType: "allow", priority: 0, + selectors: { agentId: f.agentId, connectionId: created.body.id } }, f.jwt); + expect(policy, JSON.stringify(policy)).toMatchObject({ status: 201 }); + expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "allowed" } }); + expect(calls).toBe(1); + expect(await f.request("POST", approvalPath, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + expect(await f.request("PATCH", `${policiesPath}/${policy.body.id}`, { enabled: false }, f.jwt)).toMatchObject({ status: 403 }); + return; + } + await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId)); + if (revocation === "agent") await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:use"))); + if (revocation === "user") for (const permission of ["tools:use", "tools:manage_connections"]) await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, f.userId), eq(principalPermissionGrants.permissionKey, permission))); + if (revocation === "cancelled") await server.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId)); + if (revocation === "other-agent-caller-revoked") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Caller access revoked", policyType: "block", selectors: { connectionId: created.body.id, agentId: f.agentId }, priority: 0 }); + if (revocation === "project-block") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "New project block", policyType: "block", selectors: { connectionId: created.body.id, projectId: f.projectId }, priority: 0 }); + const cookie = await server.humanSession(f.userId); + const approve = () => fetch(server.apiUrl + "/api" + approvalPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) }); + const response = await approve(); + expect(response.status, JSON.stringify(await response.json())).toBe(revocation === "cancelled" ? 409 : 200); + const [settled] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, saved.id)); + if (revocation === "cancelled") expect(settled.status).not.toBe("succeeded"); + else expect(settled, JSON.stringify(settled)).toMatchObject({ status: allowed ? "succeeded" : "failed" }); + expect(calls).toBe(allowed ? 1 : 0); + if (allowed) { expect((await approve()).status).toBe(200); expect(calls).toBe(1); } + } finally { if (freezeRateClock) vi.useRealTimers(); remote.closeAllConnections(); await new Promise((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); } + }); + + it("completes concurrent run-authenticated profile finishing and policy writes without lock-order failures", async () => { + const f = await fixture(); + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Concurrent management", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + const policy = await f.request("POST", `/companies/${f.companyId}/tools/policies`, { name: "Concurrent policy", policyType: "allow", selectors: { agentId: f.agentId } }); + expect(policy).toMatchObject({ status: 201 }); + const finishPath = `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`; + const finish = { enabledCatalogEntryIds: [], access: { agentIds: [f.agentId] } }; + expect(await f.request("POST", finishPath, finish, f.jwt)).toMatchObject({ status: 200 }); + for (let round = 0; round < 3; round++) { + const rows = await server.db.select({ id: toolPolicies.id }).from(toolPolicies).where(eq(toolPolicies.companyId, f.companyId)); + const results = await Promise.all([ + f.request("POST", `/companies/${f.companyId}/tools/policies/reorder`, { policyIds: rows.map(row => row.id) }, f.jwt), + f.request("PATCH", `/companies/${f.companyId}/tools/policies/${policy.body.id}`, { description: `Round ${round}` }, f.jwt), + f.request("POST", finishPath, finish, f.jwt), + ]); + expect([200, 422]).toContain(results[0].status); + expect(results.slice(1), JSON.stringify(results)).toEqual([expect.objectContaining({ status: 200 }), expect.objectContaining({ status: 200 })]); + } + expect((await server.db.select().from(toolPolicies).where(eq(toolPolicies.id, policy.body.id)))[0].description).toBe("Round 2"); + }); + + it("checks issue, routine, and gateway profile targets including retained bindings", async () => { + const f = await fixture(); + const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!; + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "target-checks", name: "Target checks", entries: [] }); + const gatewayProfile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-target", name: "Gateway target", entries: [] }); + const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Target gateway", slug: "binding-target", profileId: gatewayProfile.body.id, agentId: other.id }); + expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 }); + const [issue] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Other agent task", assigneeAgentId: other.id, status: "in_progress", responsibleUserId: f.userId }).returning(); + const [routine] = await server.db.insert(routines).values({ companyId: f.companyId, title: "Other agent routine", assigneeAgentId: other.id, responsibleUserId: f.userId }).returning(); + const [configurationGrant] = await server.db.select().from(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + await server.db.update(principalPermissionGrants).set({ scope: { ...configurationGrant.scope, targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + const path = `/companies/${f.companyId}/tools/profiles/${profile.body.id}`; + const ownBinding = await f.request("POST", `${path}/bind`, { targetType: "issue", targetId: f.issueId }); + expect(ownBinding, JSON.stringify(ownBinding)).toMatchObject({ status: 201 }); + expect(await f.request("POST", `${path}/unbind`, { targetType: "issue", targetId: f.issueId })).toMatchObject({ status: 200 }); + for (const target of [{ targetType: "project", targetId: f.projectId }, { targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) { + expect(await f.request("POST", `${path}/bind`, target), JSON.stringify(target)).toMatchObject({ status: 403 }); + const [saved] = await server.db.insert(toolProfileBindings).values({ ...target, companyId: f.companyId, profileId: profile.body.id }).returning(); + expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit retained target" })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `${path}/unbind`, target)).toMatchObject({ status: 403 }); + await server.db.delete(toolProfileBindings).where(eq(toolProfileBindings.id, saved.id)); + } + await server.db.update(toolMcpGateways).set({ profileId: profile.body.id }).where(eq(toolMcpGateways.id, gateway.body.id)); + expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit a gateway's retained profile" })).toMatchObject({ status: 403 }); + }); + + it("filters GitHub review history and detail by the linked task's private visibility", async () => { + const f = await fixture(); + const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId }); + const [privateIssue] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private review task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + const [visible, hidden] = await server.db.insert(chatGitHubReviews).values([f.issueId, privateIssue.id].map((issueId, index) => ({ companyId: f.companyId, endpointId: endpoint.body.id, + issueId, repositoryId: "42", repository: "fixture/repo", pullNumber: index + 1, headSha: "b".repeat(40), deliveryId: randomUUID(), configurationRevision: 1, + policySnapshot: defaultGitHubReviewPolicy(), event: { event: "opened" as const, deliveryId: randomUUID(), repositoryId: "42", repository: "fixture/repo", pullNumber: index + 1, + title: "Review", body: index ? "Private provider context" : "Public context", baseSha: "a".repeat(40), headSha: "b".repeat(40), baseBranch: "master", author: { id: "1", login: "fixture", isBot: false }, sender: { id: "1", login: "fixture" }, draft: false, labels: [] }, + }))).returning(); + const path = `/chat-endpoints/${endpoint.body.id}/github/reviews`; + expect((await f.request("GET", path)).body.map((review: { id: string }) => review.id)).toEqual([visible.id]); + expect(await f.request("GET", `${path}/${visible.id}`)).toMatchObject({ status: 200 }); + expect(await f.request("GET", `${path}/${hidden.id}`)).toMatchObject({ status: 404 }); + }); + + it("keeps narrow policy create, update, reorder, and delete within their actual audiences", async () => { + const f = await fixture(); + const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!; + await server.db.update(principalPermissionGrants).set({ scope: { targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + const path = `/companies/${f.companyId}/tools/policies`; + const created = await f.request("POST", path, { name: "Own target rule", policyType: "allow", selectors: { agentId: f.agentId } }); + expect(created, JSON.stringify(created)).toMatchObject({ status: 201 }); + expect(await f.request("PATCH", `${path}/${created.body.id}`, { description: "Retain the target" })).toMatchObject({ status: 200 }); + expect(await f.request("POST", `${path}/reorder`, { policyIds: [created.body.id] })).toMatchObject({ status: 200 }); + expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: {} })).toMatchObject({ status: 403 }); + expect(await f.request("PATCH", `${path}/${created.body.id}`, { selectors: { agentId: other.id } })).toMatchObject({ status: 403 }); + expect(await f.request("POST", path, { name: "Company rule", policyType: "allow", selectors: {} })).toMatchObject({ status: 403 }); + expect(await f.request("DELETE", `${path}/${created.body.id}`)).toMatchObject({ status: 200 }); + }); + + it("bounds and deduplicates resource selectors before per-resource database work", async () => { + const f = await fixture(); + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Bounded selector", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + let resourceReads = 0; + const originalSelect = server.db.select.bind(server.db); + const select = vi.spyOn(server.db, "select").mockImplementation(((selection: never) => { + const builder = originalSelect(selection); + const originalFrom = builder.from.bind(builder); + builder.from = ((table: never) => { + if (table === toolConnections || table === toolPolicies || table === toolCatalogEntries) resourceReads++; + return originalFrom(table); + }) as typeof builder.from; + return builder; + }) as typeof server.db.select); + try { + const path = `/companies/${f.companyId}/tools/policies`; + const body = { name: "Bounded policy", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: Array(5000).fill(created.body.id) } }; + expect(await f.request("POST", path, body)).toMatchObject({ status: 400 }); + expect(resourceReads).toBe(0); + expect(await f.request("POST", path, { ...body, selectors: { ...body.selectors, connectionIds: Array(100).fill(created.body.id) } })).toMatchObject({ status: 201 }); + expect(resourceReads).toBeLessThan(20); + resourceReads = 0; + await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended"); + expect(await f.request("POST", path, { ...body, selectors: { ...body.selectors, connectionIds: Array(100).fill(created.body.id) } })).toMatchObject({ status: 403 }); + expect(resourceReads).toBe(0); + } finally { select.mockRestore(); } + }); + + it("shares one run capture across a management read without caching authority across requests", async () => { + const f = await fixture(); + for (let index = 0; index < 5; index++) expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: `Read snapshot ${index}`, transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } })).toMatchObject({ status: 201 }); + const capture = vi.spyOn(runIdentity, "captureRunIdentity"); + try { + const listed = await f.request("GET", `/companies/${f.companyId}/tools/connections`, undefined, f.jwt); + expect(listed).toMatchObject({ status: 200 }); + expect(listed.body.connections).toHaveLength(5); + expect(capture.mock.calls.length).toBeGreaterThan(0); + expect(capture.mock.calls.length).toBeLessThanOrEqual(2); + 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/connections`, undefined, f.jwt)).toMatchObject({ status: 403 }); + } finally { capture.mockRestore(); } + }); + + it("returns the same missing-resource response for foreign and absent connection IDs", async () => { + const f = await fixture(), other = await fixture(); + const foreign = await other.request("POST", `/companies/${other.companyId}/tools/connections`, { name: "Other company", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + const hidden = await f.request("GET", `/tool-connections/${foreign.body.id}`); + const absent = await f.request("GET", `/tool-connections/${randomUUID()}`); + expect(hidden).toMatchObject({ status: 404 }); + expect(absent).toEqual(hidden); + }); + + it("checks existing and proposed provider scope without treating generic metadata as provider authority", async () => { + const f = await fixture(); + const payload = { name: "Scoped provider", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp", provider: "fixture-one" } }; + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, payload); + expect(created.status).toBe(201); + const untagged = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Untagged", config: { url: "https://1.1.1.1/mcp" } }); + const application = await f.request("POST", `/companies/${f.companyId}/tools/applications`, { name: "Provider app", type: "rest_api", metadata: { sourceTemplateKey: "fixture-one" } }); + expect(application).toMatchObject({ status: 201 }); + await server.db.update(principalPermissionGrants).set({ scope: { providers: ["fixture-one"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await f.request("PATCH", `/tool-connections/${untagged.body.id}`, { name: "Spoofed label", provider: "fixture-one" })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/applications`, { name: "Spoofed application", type: "rest_api", provider: "fixture-one", metadata: { sourceTemplateKey: "fixture-two" } })).toMatchObject({ status: 403 }); + expect(await f.request("PATCH", `/tool-applications/${application.body.id}`, { name: "Same app provider" })).toMatchObject({ status: 200 }); + for (const metadata of [{ sourceTemplateKey: "fixture-two" }, {}]) expect(await f.request("PATCH", `/tool-applications/${application.body.id}`, { provider: "fixture-one", metadata })).toMatchObject({ status: 403 }); + expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "Still the same provider" })).toMatchObject({ status: 200 }); + for (const config of [{ url: "https://1.1.1.1/mcp", provider: "fixture-two" }, { url: "https://1.1.1.1/mcp" }]) expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { config })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Caller-labelled provider" })).toMatchObject({ status: 403 }); + await server.db.update(principalPermissionGrants).set({ scope: { purposes: ["tool"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "Wrong purpose", apiKey: "fixture", ownership: "personal", agentIds: [] })).toMatchObject({ status: 403 }); + }); + + 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("saves the addressed user's Google service choices before handing off browser consent", async () => { + const f = await fixture(); + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Selected Google services", transport: "mcp_remote", authKind: "oauth", credentialPolicy: "per_user", status: "draft", + config: { url: "https://1.1.1.1/mcp", sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace" } }); + expect(created, JSON.stringify(created)).toMatchObject({ status: 201 }); + const services = { gmail: "read" as const }; + const started = await f.request("POST", `/tools/oauth/${created.body.id}/start`, { asCurrentUser: true, workspaceServices: services }); + expect(started, JSON.stringify(started)).toMatchObject({ status: 200, body: { expiresAt: null, requiredUserAction: { userId: f.userId } } }); + const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id)); + expect((saved.config.oauth as { pendingWorkspaceServiceRequests: Record }).pendingWorkspaceServiceRequests).toEqual({ + [f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services), requestedAt: expect.any(String) }, + }); + const restored = await f.request("GET", `/tool-connections/${saved.id}`); + expect(restored).toMatchObject({ status: 200, body: { config: { oauth: { pendingWorkspaceServiceRequests: { + [f.userId]: { requestedScopes: googleWorkspaceSelectedScopes(services) }, + } } } } }); + expect(await server.db.select().from(toolOauthStates).where(eq(toolOauthStates.connectionId, saved.id))).toHaveLength(0); + }); + + 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; + let revokeDuringAuth = false; + provider = async (url, init) => { + if (url.endsWith("auth.test")) { + if (revokeDuringAuth) await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended"); + 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: new URLSearchParams(String(init?.body)).get("user") ?? "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 setupPath = `/chat-endpoints/${created.body.id}/setup`; + const cookie = await server.humanSession(f.userId); + const configuredResponse = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }) }); + const configured = { status: configuredResponse.status, body: await configuredResponse.json() }; + expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 }); + expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token"); + const [configuredEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id)); + await server.db.update(chatEndpoints).set({ status: "active", setup: { ...configuredEndpoint.setup, step: "complete", webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id)); + const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!; + const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref); + expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt)) + .toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } }); + let credentialIntentId: string | undefined; + for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) { + const chosen = "agent-chosen-webhook-key"; + const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token); + expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id, + requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } }); + expect(JSON.stringify(handoff.body)).not.toContain(chosen); + expect(handoff.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1"); + const entryLink = new URL(handoff.body.requiredUserAction.setupPath, server.apiUrl); + expect.soft(entryLink.searchParams.get("credentialFields")).toBe("signingSecret"); + expect.soft(entryLink.searchParams.get("credentialMode")).toBe("all"); + if (token === f.jwt) { + if (credentialIntentId) expect(handoff.body.interactionId).toBe(credentialIntentId); + credentialIntentId = handoff.body.interactionId; + } + const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs); + const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!; + expect(await secretService(server.db).resolveSecretValue(f.companyId, signing.secretId, "latest", { + accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" }, + })).toBe("fixture-signing-secret"); + } + const [credentialIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)); + const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + const entryProof = (credentialIntent.payload as { savedSetup: { credentialEntry: { previousGenerations: Record; requireAll: boolean } } }).savedSetup.credentialEntry; + expect(entryProof).toEqual({ previousGenerations: { signingSecret: expect.any(String) }, requireAll: true }); + expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull(); + await satisfiedConnectionIntentService(server.db).sweepPending(); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("pending"); + // Ordinary write-only bot-token repair remains delegated under current authority. + expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } }); + const healthy = async () => { + 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)); + }; + await healthy(); + expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull(); + const humanReconnect = async (signingSecret: string) => { + const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "reconnect", credentials: { signingSecret, botToken: "xoxb-fixture-token" } }) }); + expect({ status: response.status, body: await response.json() }).toMatchObject({ status: 200 }); + }; + await humanReconnect("fixture-signing-secret"); + await healthy(); + expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull(); + await humanReconnect("human-replacement-signing-secret"); + expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull(); + const resumedRequest = await f.request("POST", setupPath, { action: "reconnect", credentials: { signingSecret: "agent-chosen-webhook-key" } }, f.jwt); + expect(resumedRequest.body.interactionId).toBe(credentialIntentId); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].payload) + .toMatchObject({ savedSetup: { credentialEntry: entryProof } }); + await healthy(); + expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull(); + const [repaired] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id)); + await server.db.update(chatEndpoints).set({ setup: { ...repaired.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id)); + expect((await findSatisfiedToolConnection(server.db, task, credentialIntent))?.id).toBe(created.body.connectionId); + expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, credentialIntent)).toBeNull(); + await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended"); + expect(await findSatisfiedToolConnection(server.db, task, credentialIntent)).toBeNull(); + await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active"); + await satisfiedConnectionIntentService(server.db).sweepPending(); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialIntentId!)))[0].status).toBe("expired"); + // Replacing complete credentials repairs unavailable old vault material. + for (const state of ["disabled", "deleted", "unreadable"] as const) { + const [connection] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + const signing = connection.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!; + if (state === "unreadable") { + await server.db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, signing.secretId)); + await server.db.update(companySecretVersions).set({ material: {} }).where(eq(companySecretVersions.secretId, signing.secretId)); + } else { + await server.db.update(companySecrets).set({ status: state }).where(eq(companySecrets.id, signing.secretId)); + } + const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "reconnect", credentials: { signingSecret: `human-repair-${state}`, botToken: "xoxb-fixture-token" } }) }); + expect.soft({ status: response.status, body: await response.json() }, `repair ${state} signing secret`).toMatchObject({ status: 200 }); + if (response.status === 200) { + const [current] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + const replacement = current.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!; + expect(replacement.secretId).not.toBe(signing.secretId); + expect(await secretService(server.db).resolveSecretValue(f.companyId, replacement.secretId, "latest", { + accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" }, + })).toBe(`human-repair-${state}`); + } + } + // Unexpected resolver failures must abort before writing new references. + const [beforeFault] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + const resolverFault = vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValue(new Error("Unexpected secret resolution failure")); + try { + const response = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "reconnect", credentials: { signingSecret: "human-repair-unexpected", botToken: "xoxb-fixture-token" } }) }); + expect(response.status).toBe(500); + expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs).toEqual(beforeFault.credentialSecretRefs); + } finally { resolverFault.mockRestore(); } + // 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)); + vi.useFakeTimers({ toFake: ["Date"] }); + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One Slack setup send", policyType: "rate_limit", selectors: { connectionId: created.body.connectionId }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } }); + 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(await f.request("POST", testPath, { requestId: randomUUID() })).toMatchObject({ status: 403, body: { details: { reasonCode: "rate_limited" } } }); + expect(await f.request("POST", testPath, { requestId: randomUUID(), slackUserId: "UOTHER" })).toMatchObject({ status: 403 }); + expect(sent).toBe(1); + vi.useRealTimers(); + const [beforeRevocation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + revokeDuringAuth = true; + expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 403 }); + expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs) + .toEqual(beforeRevocation.credentialSecretRefs); + } finally { vi.useRealTimers(); provider = null; } + }); + + it.each(["linked", "invitation"] as const)("checks caller and target policy for delegated Slack %s sends", async recipient => { + for (const mode of ["caller-block", "caller-ask", "caller-rate", "target-block", "target-rate", "shared-rate"] as const) { + const f = await fixture(); + const cookie = await server.humanSession(f.userId); + const targetResponse = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, { + method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Slack target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, + permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }), + }); + const target = await targetResponse.json(); + expect(targetResponse.status, JSON.stringify(target)).toBe(201); + await lifecycle.process(target.id); + await vi.waitFor(async () => expect((await agentService(server.db).getById(target.id))?.status).toBe("idle")); + let sends = 0; + const workspaceId = `T${f.companyId.replaceAll("-", "").toUpperCase()}`; + const scopes = [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES]; + provider = async (url, init) => { + if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: workspaceId, team: "Delegated fixture", user_id: `U${target.id.replaceAll("-", "").toUpperCase()}`, 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: new URLSearchParams(String(init?.body)).get("user"), team_id: workspaceId, name: "Fixture user", is_bot: false, deleted: false } }); + if (url.endsWith("chat.postMessage")) { sends++; 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: target.id, slackSetupMethod: "manual" }); + expect(created, JSON.stringify(created)).toMatchObject({ status: 201 }); + const configured = await fetch(`${server.apiUrl}/api/chat-endpoints/${created.body.id}/setup`, { + method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "configure", credentials: { botToken: `xoxb-${target.id}`, signingSecret: "fixture-signing-secret" } }), + }); + expect({ status: configured.status, body: await configured.json() }).toMatchObject({ status: 200 }); + await server.db.update(chatEndpoints).set({ status: "active", allowDirectMessages: true }).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)); + if (recipient === "linked") { + const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: workspaceId, 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.insert(toolPolicies).values({ companyId: f.companyId, name: "Slack fixture access", policyType: "allow", selectors: { connectionId: created.body.connectionId }, priority: 2 }); + const rate = mode.endsWith("rate"); + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: mode, policyType: rate ? "rate_limit" : mode === "caller-ask" ? "require_approval" : "block", + selectors: { connectionId: created.body.connectionId, ...(mode === "shared-rate" ? {} : { agentId: mode.startsWith("caller") ? f.agentId : target.id }) }, priority: 0, + ...(rate ? { config: { limit: 1, windowSeconds: 60, keyBy: mode === "shared-rate" ? ["company", "tool"] : ["agent", "tool"] } } : {}), + }); + const path = `/chat-endpoints/${created.body.id}/slack/test-message`; + if (rate) { + // Shared buckets admit one actual send, while each principal's own + // limit also prevents the next send. Neither may consume twice. + expect(await f.request("POST", path, { requestId: randomUUID(), slackUserId: "UHUMAN" }, f.jwt), mode) + .toMatchObject({ status: 200, body: { status: recipient === "linked" ? "sent" : "connection_pending" } }); + expect(sends, mode).toBe(1); + } + for (const token of [f.key.token, f.jwt]) { + const denied = await f.request("POST", path, { requestId: randomUUID(), slackUserId: "UHUMAN" }, token); + expect(denied, `${mode}: ${JSON.stringify(denied)}`).toMatchObject({ status: 403, body: { details: { reasonCode: rate ? "rate_limited" : mode === "caller-ask" ? "requires_approval_policy" : "deny_policy_block" } } }); + if (mode === "caller-ask") expect(denied.body.details.requiredUserAction).toMatchObject({ kind: "approve" }); + } + expect(sends, mode).toBe(rate ? 1 : 0); + const audit = await server.db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.companyId)); + expect(audit.some(event => event.details.agentId === f.agentId && event.actorId === f.agentId), mode).toBe(true); + } finally { provider = null; } + } + }, 90_000); + + 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 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 appCredentials = { appId: String(appId), privateKey, webhookSecret: "fixture-webhook-secret", clientId: "fixture-client", clientSecret: "fixture-secret" }; + expect(await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, appCredentials)).toMatchObject({ status: 200, body: { status: "needs_user_action" } }); + const importHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, {}, f.jwt); + expect(importHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials", userId: f.userId } } }); + expect.soft(importHandoff.body.requiredUserAction.setupPath).toContain("stage=credentials"); + expect.soft(importHandoff.body.requiredUserAction.setupPath).not.toContain("reconnect=1"); + const privateKeyHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { privateKey } }, f.jwt); + expect.soft(privateKeyHandoff, JSON.stringify(privateKeyHandoff)).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials" } } }); + if (privateKeyHandoff.status === 200) { + const entryLink = new URL(privateKeyHandoff.body.requiredUserAction.setupPath, server.apiUrl); + expect.soft(entryLink.searchParams.get("credentialFields")).toBe("privateKey"); + expect.soft(entryLink.searchParams.get("credentialMode")).toBe("all"); + expect(privateKeyHandoff.body.interactionId).not.toBe(importHandoff.body.interactionId); + const retry = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { privateKey } }, f.jwt); + expect(retry.body.interactionId).toBe(privateKeyHandoff.body.interactionId); + } + expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)))[0].credentialSecretRefs).toEqual([]); + const imported = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/app`, "POST", appCredentials); + expect(imported.status).toBe(200); + expect(JSON.stringify(await imported.json())).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 }); + 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)); + const identityHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/github/identity/start`, {}, f.jwt); + expect(identityHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "confirm_identity" } } }); + const [identityIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, identityHandoff.body.interactionId)); + const [identityTask] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(await findSatisfiedToolConnection(server.db, identityTask, identityIntent)).toBeNull(); + 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); + expect(await findSatisfiedToolConnection(server.db, identityTask, identityIntent)).toBeNull(); + 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); + const [endpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id)); + await server.db.update(chatEndpoints).set({ status: "active", setup: { ...endpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, endpoint.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, identityTask, identityIntent))?.id).toBe(created.body.connectionId); + const credentialHandoff = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "reconnect", credentials: { webhookSecret: "agent-chosen-key" } }, f.jwt); + expect(credentialHandoff).toMatchObject({ status: 200, body: { requiredUserAction: { kind: "enter_credentials" } } }); + const entryLink = new URL(credentialHandoff.body.requiredUserAction.setupPath, server.apiUrl); + expect(entryLink.searchParams.get("reconnect")).toBe("1"); + expect.soft(entryLink.searchParams.get("credentialFields")).toBe("webhookSecret"); + expect.soft(entryLink.searchParams.get("credentialMode")).toBe("all"); + const entryOptionsResponse = await humanRequest(`/api/connection-intents/${credentialHandoff.body.interactionId}/setup-options`); + const entryOptions = await entryOptionsResponse.json(); + expect.soft(entryOptions.chatSetup.path).toBe(credentialHandoff.body.requiredUserAction.setupPath); + const [entryIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, credentialHandoff.body.interactionId)); + const [entryTask] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(await findSatisfiedToolConnection(server.db, entryTask, entryIntent)).toBeNull(); + const repairedResponse = await humanRequest(`/api/chat-endpoints/${created.body.id}/setup`, "POST", { action: "reconnect", credentials: { webhookSecret: "human-replacement-webhook" } }); + expect(repairedResponse.status).toBe(200); + const [repairedConnection] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + for (const [field, value] of [["privateKey", privateKey], ["clientSecret", "fixture-secret"], ["webhookSecret", "human-replacement-webhook"]]) { + const ref = repairedConnection.credentialSecretRefs.find(ref => ref.configPath === `credentials.${field}`)!; + expect(await secretService(server.db).resolveSecretValue(f.companyId, ref.secretId, "latest", { + accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" }, + })).toBe(value); + } + expect(await findSatisfiedToolConnection(server.db, entryTask, entryIntent)).toBeNull(); + const [repairedEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, created.body.id)); + expect(repairedEndpoint.setup.webhookVerifiedAt).toBeNull(); + 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, entryTask, entryIntent)).toBeNull(); + await server.db.update(chatEndpoints).set({ setup: { ...repairedEndpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, created.body.id)); + expect((await findSatisfiedToolConnection(server.db, entryTask, entryIntent))?.id).toBe(created.body.connectionId); + const [beforeRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + let rotationIntentId = ""; + for (const token of [f.key.token, f.jwt, f.jwt]) { + const rotation = await f.request("POST", `/chat-endpoints/${created.body.id}/setup-secret`, {}, token); + expect(rotation, JSON.stringify(rotation)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id, + requiredUserAction: { kind: "enter_credentials", userId: f.userId } } }); + expect(rotation.body).not.toHaveProperty("webhookSecret"); + expect(rotation.body.requiredUserAction.setupPath).toContain("stage=credentials&reconnect=1&rotateSecret=1"); + if (token === f.jwt) { + if (rotationIntentId) expect(rotation.body.interactionId).toBe(rotationIntentId); + rotationIntentId = rotation.body.interactionId; + } + } + expect(rotationIntentId).not.toBe(importHandoff.body.interactionId); + const [afterRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(afterRotation.credentialSecretRefs).toEqual(beforeRotation.credentialSecretRefs); + const [rotationIntent] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, rotationIntentId)); + const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + const setupOptions = await humanRequest(`/api/connection-intents/${rotationIntentId}/setup-options`); + expect(setupOptions.status).toBe(200); + expect((await setupOptions.json()).chatSetup.path).toContain("stage=credentials&reconnect=1&rotateSecret=1"); + expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull(); + await satisfiedConnectionIntentService(server.db).sweepPending(); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, rotationIntentId)))[0].status).toBe("pending"); + const humanRotation = await humanRequest(`/api/chat-endpoints/${created.body.id}/setup-secret`, "POST", {}); + expect(humanRotation.status).toBe(201); + const generatedRotation = await humanRotation.json(); + expect(generatedRotation).toMatchObject({ webhookSecret: expect.stringMatching(/^[a-f0-9]{64}$/), credentialRotationId: expect.any(String) }); + const [humanRotated] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(humanRotated.credentialSecretRefs).not.toEqual(beforeRotation.credentialSecretRefs); + const rotationState = await f.request("GET", `/chat-endpoints/${endpoint.id}`); + expect(rotationState.body.setup).toMatchObject({ credentialRotationId: expect.any(String), webhookVerifiedAt: null }); + expect(rotationState.body.setup.credentialRotationId).toBe(generatedRotation.credentialRotationId); + const nextRotation = await f.request("POST", `/chat-endpoints/${endpoint.id}/setup-secret`, {}); + expect(nextRotation.body.requiredUserAction.setupPath).toContain(`afterRotationId=${rotationState.body.setup.credentialRotationId}`); + expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull(); + // Provider-fixture states separate restored health from new webhook verification. + const [rotatedEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)); + await server.db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpoint.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, rotationIntent)).toBeNull(); + await server.db.update(chatEndpoints).set({ setup: { ...rotatedEndpoint.setup, webhookVerifiedAt: new Date().toISOString() } }).where(eq(chatEndpoints.id, endpoint.id)); + expect((await findSatisfiedToolConnection(server.db, task, rotationIntent))?.id).toBe(created.body.connectionId); + } 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); + const other = await f.request("POST", `/companies/${f.companyId}/email/connections`, { ...body, apiKey: "other-agentmail-secret", grantKind: "organization", idempotencyKey: randomUUID() }); + expect(other).toMatchObject({ status: 201 }); + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [created.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + for (const token of [f.key.token, f.jwt]) { + const listed = await f.request("GET", `/companies/${f.companyId}/email/connections?connectionId=${created.body.id}`, undefined, token); + expect(listed, JSON.stringify(listed)).toMatchObject({ status: 200 }); + expect(listed.body.map((account: { id: string }) => account.id)).toEqual([created.body.id]); + } + } finally { provider = null; } + }); + + it("shares tool-activity authorization by task and reuses run identity within one page", async () => { + const f = await fixture(); + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Activity page", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + expect(connection).toMatchObject({ status: 201 }); + const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private activity task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + await server.db.insert(toolCallEvents).values(Array.from({ length: 100 }, (_, index) => ({ companyId: f.companyId, connectionId: connection.body.id, + eventType: "policy_decision" as const, actorType: "agent", actorId: f.agentId, issueId: index % 2 ? f.issueId : hidden.id, toolName: index % 2 ? "visible" : "private_activity" }))); + const original = connectionManagementModule.connectionManagementService; + const checked: string[] = []; + const authorization = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation((db) => { + const service = original(db); + return { ...service, capability: async (...args: Parameters) => { + if (args[1] === "connections:audit") checked.push(args[2].issueId ?? "unbound"); + return service.capability(...args); + } }; + }); + const capture = vi.spyOn(runIdentity, "captureRunIdentity"); + try { + const path = `/tool-connections/${connection.body.id}/activity?limit=100`; + const result = await f.request("GET", path, undefined, f.jwt); + expect(result, JSON.stringify(result)).toMatchObject({ status: 200 }); + expect(result.body.events).toHaveLength(50); + expect(JSON.stringify(result.body)).not.toContain("private_activity"); + expect(checked, JSON.stringify({ checks: checked.length, captures: capture.mock.calls.length })).toHaveLength(2); + expect(capture.mock.calls.length).toBeGreaterThan(0); + expect(capture.mock.calls.length).toBeLessThanOrEqual(2); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:view_audit"))); + expect(await f.request("GET", path, undefined, f.jwt)).toMatchObject({ status: 403 }); + } finally { authorization.mockRestore(); capture.mockRestore(); } + }); + + it("bounds activity authorization to the requested page rather than endpoint history", async () => { + const f = await fixture(); + const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId }); + expect(endpoint).toMatchObject({ status: 201 }); + const conversations = await server.db.insert(chatConversations).values(Array.from({ length: 200 }, (_, index) => ({ + companyId: f.companyId, endpointId: endpoint.body.id, issueId: f.issueId, + externalConversationId: `history-${index}`, externalLabel: `History ${index}`, + }))).returning(); + const [delivery] = await server.db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId: endpoint.body.id, + conversationId: conversations[0].id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "mention", state: "processed", normalizedEvent: {} }).returning(); + const original = connectionManagementModule.connectionManagementService; + const checked: string[] = []; + const authorization = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation((db) => { + const service = original(db); + return { ...service, capability: async (...args: Parameters) => { + if (args[1] === "connections:audit") checked.push(args[2].issueId ?? "unbound"); + return service.capability(...args); + } }; + }); + try { + const result = await f.request("GET", `/chat-endpoints/${endpoint.body.id}/activity?limit=1`, undefined, f.jwt); + expect(result, JSON.stringify(result)).toMatchObject({ status: 200, body: { items: [expect.objectContaining({ id: delivery.id })] } }); + expect(checked).toEqual([f.issueId]); + } finally { authorization.mockRestore(); } + }); + + it("continues activity pagination through private-only windows", async () => { + const f = await fixture(); + const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId }); + const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private history", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + const conversations = await server.db.insert(chatConversations).values([f.issueId, hidden.id].map(issueId => ({ companyId: f.companyId, endpointId: endpoint.body.id, issueId, + externalConversationId: randomUUID(), externalLabel: "History" }))).returning(); + const rows = Array.from({ length: 30 }, (_, index) => ({ id: `${f.agentId.slice(0, 24)}${index.toString(16).padStart(12, "0")}`, + companyId: f.companyId, endpointId: endpoint.body.id, conversationId: conversations[index < 3 ? 0 : 1].id, + providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: index < 3 ? "readable_history" : "private_history", state: "processed", normalizedEvent: {}, createdAt: new Date("2026-10-25T12:00:00.000Z") })); + await server.db.insert(chatDeliveries).values(rows); + const path = `/chat-endpoints/${endpoint.body.id}/activity?limit=25`; + const first = await f.request("GET", path, undefined, f.jwt); + expect(first).toMatchObject({ status: 200, body: { items: [], nextCursor: expect.any(String) } }); + expect(JSON.stringify(first.body)).not.toContain("private_history"); + const second = await f.request("GET", `${path}&cursor=${encodeURIComponent(first.body.nextCursor)}`, undefined, f.jwt); + expect(second).toMatchObject({ status: 200, body: { items: rows.slice(0, 3).reverse().map(row => expect.objectContaining({ id: row.id })), nextCursor: null } }); + expect(JSON.stringify(second.body)).not.toContain("private_history"); + }); + + it("saves delegated Arcade discovery for the responsible user and shares its inventory with that user", async () => { + const f = await fixture(); + const transport = vi.spyOn(remoteHttpFetch, "guardedRemoteHttpFetch").mockImplementation(async (url, init) => provider!(String(url), init)); + provider = async (url, init) => { + const path = new URL(url).pathname; + const rpc = init?.body ? JSON.parse(String(init.body)) : null; + if (rpc?.method === "initialize") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } }); + if (rpc?.method === "notifications/initialized") return new Response(null, { status: 202 }); + if (rpc?.method === "tools/list") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools: [{ name: "Notion.ListPages", inputSchema: { type: "object" } }] } }); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-arcade-discovery"); + if (path === "/v1/tools") return Response.json({ items: [{ qualified_name: "Notion.ListPages", toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion", token_status: "completed" } } }] }); + if (path === "/v1/admin/user_connections") return Response.json({ items: [{ id: "arcade-work", provider_id: "notion", user_id: "arcade-user", connection_status: "active" }] }); + throw new Error(`Unexpected Arcade fixture operation: ${path}`); + }; + try { + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Arcade fixture", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: "arcade", url: "https://api.arcade.dev/mcp/test" } }); + expect(created, JSON.stringify(created)).toMatchObject({ status: 201 }); + const configured = await f.request("PUT", `/tool-connections/${created.body.id}/aggregator/discovery`, { apiKey: "fixture-arcade-discovery", userId: "arcade-user" }, f.jwt); + expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 }); + const [saved] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id)); + const discovery = saved.config.aggregatorDiscovery as Record; + expect(discovery[f.agentId]).toBeUndefined(); + expect(discovery[f.userId]).toMatchObject({ userId: "arcade-user", secretId: expect.any(String) }); + const [secret] = await server.db.select().from(companySecrets).where(eq(companySecrets.id, discovery[f.userId].secretId)); + expect(secret.ownerUserId).toBe(f.userId); + const events = await server.db.select().from(activityLog).where(eq(activityLog.entityId, saved.id)); + expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ action: "tool_connection.account_sync_configured", actorType: "agent", actorId: f.agentId, responsibleUserId: f.userId })])); + const cookie = await server.humanSession(f.userId); + await vi.waitFor(async () => { + const response = await fetch(`${server.apiUrl}/api/tool-connections/${saved.id}/aggregator/apps`, { headers: { cookie } }); + const result = await response.json(); + expect(result.discovery).toMatchObject({ availability: "available" }); + expect(result.apps).toEqual(expect.arrayContaining([expect.objectContaining({ provider: "arcade", appSlug: "notion" })])); + }, { timeout: 10_000 }); + } finally { transport.mockRestore(); provider = null; } + }); + + it("encrypts native local-login checks and replays without exposing device codes in receipts or assets", async () => { + const f = await fixture(); + const priorHome = process.env.PAPERCLIP_HOME; + vi.stubEnv("PAPERCLIP_HOME", join(server.root, "local-login-receipt")); + const authorizationUrl = "https://fixture.invalid/device?private_handoff=disposable-login"; + const code = "DISPOSABLE-DEVICE-CODE"; + const browser = vi.spyOn(localBrowserLogin, "startLocalBrowserLogin").mockReturnValue({ authorizationUrl, code, abort: vi.fn() }); + const intent = { provider: "openai", method: "subscription", ownership: "personal", name: "Local receipt fixture", agentIds: [], allAgents: false }; + let sessionId: string | undefined; + try { + const started = await f.request("POST", `/companies/${f.companyId}/ai-connections/local/attempts`, intent, f.jwt); + expect(started, JSON.stringify(started)).toMatchObject({ status: 201, body: { sessionId: expect.any(String) } }); + sessionId = started.body.sessionId; + const call = { tool: "call_api", callId: "local-login-private-check", arguments: { + operationId: "POST /api/companies/{companyId}/ai-connections/local/check", body: { ...intent, localSessionId: sessionId }, + } }; + const result = await f.authority.execute(call); + expect(result).toMatchObject({ ok: true, status: 200, data: { status: "sign_in_required", authorizationUrl, code } }); + expect(result).not.toHaveProperty("artifact"); + expect(await f.authority.execute(call)).toEqual(result); + const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const receipts = (run.resultJson as { apiToolReceipts: Record }).apiToolReceipts; + expect(Object.values(receipts).find(receipt => receipt.operationId === call.arguments.operationId)).toHaveProperty("encryptedResult"); + for (const secret of [authorizationUrl, code]) { + expect(JSON.stringify(run.resultJson)).not.toContain(secret); + expect(JSON.stringify(redactRunnerApiPayloads(result))).not.toContain(secret); + } + expect((await f.snapshot()).assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]); + } finally { + if (sessionId) await f.request("DELETE", `/companies/${f.companyId}/ai-connections/local/attempts/${sessionId}`, undefined, f.jwt); + browser.mockRestore(); + if (priorHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = priorHome; + } + }); + + it("keeps durable gateway client tokens bound to the issuer and responsible user", async () => { + const f = await fixture(); + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-profile", name: "Gateway profile", entries: [] }); + expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 }); + const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Delegated gateway", slug: "delegated", profileId: profile.body.id }); + expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 }); + const call = { tool: "call_api", callId: "gateway-client-secret", arguments: { + operationId: "POST /api/tool-gateway/gateways/{gatewayId}/tokens", pathParams: { gatewayId: gateway.body.id }, + body: { companyId: f.companyId, name: "Fixture client", clientLabel: "fixture", expiresAt: new Date(Date.now() + 3600000).toISOString() }, + } }; + const issued = await f.authority.execute(call) as { status: number; data: { id: string; token: string } }; + expect(issued, JSON.stringify(issued)).toMatchObject({ status: 201, data: { token: expect.any(String), responsibleUserId: f.userId } }); + expect(await f.authority.execute(call)).toEqual(issued); + const token = { status: issued.status, body: issued.data }; + const [receiptRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(JSON.stringify(receiptRun.resultJson)).not.toContain(token.body.token); + const receipts = (receiptRun.resultJson as { apiToolReceipts: Record }).apiToolReceipts; + expect(Object.values(receipts).find(receipt => receipt.operationId === call.arguments.operationId)).toHaveProperty("encryptedResult"); + expect(JSON.stringify(redactRunnerApiPayloads(issued))).not.toContain(token.body.token); + expect(await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.gatewayId, gateway.body.id))).toHaveLength(1); + const [saved] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id)); + expect(saved).toMatchObject({ createdByAgentId: f.agentId, responsibleUserId: f.userId, subjectType: "gateway_client" }); + const use = () => f.request("POST", `/mcp/gateways/${gateway.body.gatewayPublicId}`, { jsonrpc: "2.0", id: 1, method: "tools/list" }, token.body.token); + expect(await use()).toMatchObject({ status: 200 }); + await server.db.update(principalPermissionGrants).set({ scope: { purpose: "tool" } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await use()).toMatchObject({ status: 200 }); + await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId)); + expect(await use()).toMatchObject({ status: 200 }); + await server.db.update(companyMemberships).set({ status: "suspended" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId))); + expect((await use()).status).toBeGreaterThanOrEqual(400); + await server.db.update(companyMemberships).set({ status: "active" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId))); + expect(await use()).toMatchObject({ status: 200 }); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect((await use()).status).toBeGreaterThanOrEqual(400); + // Model the token FK's ON DELETE SET NULL state without deleting the + // fixture's protected historical activity references to the agent. + await server.db.update(toolMcpGatewayTokens).set({ createdByAgentId: null }).where(eq(toolMcpGatewayTokens.id, token.body.id)); + const [orphaned] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id)); + expect(orphaned).toMatchObject({ createdByAgentId: null, responsibleUserId: f.userId }); + expect((await use()).status).toBeGreaterThanOrEqual(400); + }); + + it.each(["block", "ask", "ask-board", "ask-revoked", "ask-rate", "ask-token-revoked", "ask-token-deleted", "ask-token-scope", "ask-grant-revoked", "rate", "shared-rate", "target-block", "credential"] as const)("intersects gateway issuer and target rules (%s)", async mode => { + const f = await fixture(); + const cookie = await server.humanSession(f.userId); + const createdTarget = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/agents`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Gateway target", role: "engineer", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" }, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }) }); + const target = await createdTarget.json(); + expect(createdTarget.status).toBe(201); + await lifecycle.process(target.id); + await vi.waitFor(async () => expect((await agentService(server.db).getById(target.id))?.status).toBe("idle")); + let calls = 0; + const remote = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const rpc = JSON.parse(body || "{}"); + let result: unknown; + if (rpc.method === "initialize") result = { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "Fixture", version: "1" } }; + else if (rpc.method === "tools/list") result = { tools: [{ name: "fixture_probe", inputSchema: { type: "object" } }] }; + else if (rpc.method === "tools/call") { calls++; result = { content: [{ type: "text", text: "Fixture gateway result" }] }; } + else { res.writeHead(202); res.end(); return; } + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result })); + }); + await new Promise(resolve => remote.listen(0, "127.0.0.1", resolve)); + const address = remote.address(); + if (!address || typeof address === "string") throw new Error("No fixture address"); + try { + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Gateway policy fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: `http://127.0.0.1:${address.port}/mcp` } }); + expect(connection.status).toBe(201); + await server.db.update(toolConnections).set({ healthStatus: "ok" }).where(eq(toolConnections.id, connection.body.id)); + const [entry] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: connection.body.applicationId, connectionId: connection.body.id, + entryKind: "tool", name: "Fixture probe", toolName: "fixture_probe", inputSchema: { type: "object" }, riskLevel: "read", isReadOnly: true, status: "active", versionHash: "fixture-v1" }).returning(); + expect(await f.request("POST", `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`, { enabledCatalogEntryIds: [entry.id], access: { agentIds: [f.agentId, target.id] } })).toMatchObject({ status: 200 }); + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-rules", name: "Gateway rules", entries: [{ selectorType: "connection", connectionId: connection.body.id }] }); + const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Cross-agent gateway", slug: "cross-agent", profileId: profile.body.id, agentId: target.id, issueId: f.issueId, projectId: f.projectId }); + expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 }); + const tokenBody = { companyId: f.companyId, name: "Issuer client", clientLabel: "fixture" }; + const token = mode === "ask-board" ? await (async () => { + const response = await fetch(`${server.apiUrl}/api/tool-gateway/gateways/${gateway.body.id}/tokens`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify(tokenBody) }); + return { status: response.status, body: await response.json() }; + })() : await f.request("POST", `/tool-gateway/gateways/${gateway.body.id}/tokens`, tokenBody); + expect(token, JSON.stringify(token)).toMatchObject({ status: 201 }); + const path = `/mcp/gateways/${gateway.body.gatewayPublicId}`; + const rpc = (method: string, params?: unknown) => f.request("POST", path, { jsonrpc: "2.0", id: randomUUID(), method, ...(params ? { params } : {}) }, token.body.token); + const listed = await rpc("tools/list"); + const tool = listed.body.result?.tools?.find((tool: { name: string }) => tool.name.includes("fixture-probe")); + expect(tool, JSON.stringify(listed)).toBeTruthy(); + vi.useFakeTimers({ toFake: ["Date"] }); + const isRate = mode === "rate" || mode === "shared-rate"; + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Gateway caller control", policyType: isRate ? "rate_limit" : mode.startsWith("ask") ? "require_approval" : mode === "credential" ? "allow" : "block", + selectors: { connectionId: connection.body.id, ...(mode === "shared-rate" ? {} : { agentId: mode === "target-block" || mode === "ask-board" ? target.id : f.agentId }) }, priority: 0, + ...(isRate ? { config: { limit: 1, windowSeconds: 60, keyBy: mode === "shared-rate" ? ["company", "tool"] : ["agent", "tool"] } } : {}) }); + if (mode === "ask-rate") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One issuer admission", policyType: "rate_limit", selectors: { connectionId: connection.body.id, agentId: f.agentId }, priority: -1, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } }); + if (mode === "credential") { + await server.db.update(toolConnections).set({ credentialPolicy: "per_agent" }).where(eq(toolConnections.id, connection.body.id)); + await server.db.insert(connectionGrants).values([f.agentId, target.id].map(subjectAgentId => ({ companyId: f.companyId, connectionId: connection.body.id, kind: "agent" as const, subjectAgentId, status: "active" }))); + } + const called = await rpc("tools/call", { name: tool.name, arguments: {} }); + if (isRate) { + expect(calls, JSON.stringify(called)).toBe(1); + await rpc("tools/call", { name: tool.name, arguments: { another: true } }); + expect(calls).toBe(1); + return; + } + expect(calls, JSON.stringify(called)).toBe(0); + if (mode === "credential") { + expect(JSON.stringify(called.body)).toContain("gateway_credential_identity_mismatch"); + return; + } + const [invocation] = await server.db.select().from(toolInvocations).where(eq(toolInvocations.gatewayId, gateway.body.id)); + expect(invocation).toMatchObject({ gatewayTokenId: token.body.id, actorId: mode === "ask-board" ? token.body.id : f.agentId, agentId: target.id, policyDecision: mode.startsWith("ask") ? "require_approval" : "deny" }); + if (!mode.startsWith("ask")) return; + const [action] = await server.db.select().from(toolActionRequests).where(eq(toolActionRequests.invocationId, invocation.id)); + expect(action.requestedByAgentId).toBe(mode === "ask-board" ? null : f.agentId); + if (mode === "ask-revoked") await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Issuer blocked after review", policyType: "block", selectors: { connectionId: connection.body.id, agentId: f.agentId }, priority: -1 }); + if (mode === "ask-token-revoked") await server.db.update(toolMcpGatewayTokens).set({ revokedAt: new Date() }).where(eq(toolMcpGatewayTokens.id, token.body.id)); + if (mode === "ask-token-deleted") await server.db.delete(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, token.body.id)); + if (mode === "ask-token-scope") await server.db.update(toolMcpGatewayTokens).set({ allowedActions: ["tools/list"] }).where(eq(toolMcpGatewayTokens.id, token.body.id)); + if (mode === "ask-grant-revoked") await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + const approvalDenied = ["ask-revoked", "ask-token-revoked", "ask-token-deleted", "ask-token-scope", "ask-grant-revoked"].includes(mode); + const response = await fetch(`${server.apiUrl}/api/tool-gateway/action-requests/${action.id}/approve`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) }); + expect(response.status, JSON.stringify(await response.json())).toBe(approvalDenied ? 403 : 200); + expect(calls).toBe(approvalDenied ? 0 : 1); + if (mode === "ask-rate") { + await rpc("tools/call", { name: tool.name, arguments: { another: true } }); + expect(calls).toBe(1); + } + } finally { vi.useRealTimers(); await new Promise((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); } + }); + + 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"); + }; + registerServerAdapter({ ...getServerAdapter("paperclip_runner"), testEnvironment: async () => ({ adapterType: "paperclip_runner", status: "pass", checks: [], testedAt: new Date().toISOString() }) }); + 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; unregisterServerAdapter("paperclip_runner"); } + }); + + it("filters AI account and pool listings by the agent's resolved connection scope", 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 accounts: { connectionId: string; grantId: string }[] = []; + for (const name of ["Allowed account", "Outside agent scope"]) { + const account = await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name, ownership: "personal", apiKey: `fixture-${name}`, agentIds: [] }); + expect(account, JSON.stringify(account)).toMatchObject({ status: 201 }); + accounts.push(account.body); + } + await instanceSettingsService(server.db).updateExperimental({ enableAiConnectionRouters: true }); + const pluginKey = `fixture.scope.${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 savedPools: { id: string }[] = []; + for (const account of accounts) { + const pool = await f.request("POST", `/companies/${f.companyId}/ai-connection-pools`, { pluginKey, config: { name: `Pool ${account.connectionId}`, enabled: true, mode: "round_robin", thresholdPercent: 90, + members: [{ id: randomUUID(), binding: { mode: "delegated", provider: "openai", method: "api_key", ...account }, profile: { provider: "codex", model: "gpt-5.6-sol", effort: "low" } }] } }); + expect(pool, JSON.stringify(pool)).toMatchObject({ status: 200 }); + savedPools.push(pool.body); + } + const path = `/companies/${f.companyId}/ai-connections?connectionId=${accounts[0].connectionId}`; + expect((await f.request("GET", path)).body.connections).toHaveLength(2); + expect((await f.request("GET", path)).body.pools).toHaveLength(2); + for (const connectionIds of [[accounts[0].connectionId], [accounts[0].connectionId, ...savedPools.map(pool => pool.id)]]) { + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + for (const token of [f.key.token, f.jwt]) { + const listed = await f.request("GET", path, undefined, token); + expect(listed, JSON.stringify(listed)).toMatchObject({ status: 200 }); + expect(listed.body.connections.map((account: { id: string }) => account.id)).toEqual([accounts[0].connectionId]); + expect(listed.body.pools.map((pool: { id: string }) => pool.id)).toEqual(connectionIds.length === 1 ? [] : [savedPools[0].id]); + } + } + } 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(); + // Healthy App credentials alone do not prove the addressee's identity. + await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId)); + await server.db.update(chatEndpoints).set({ status: "active", providerAccountId: "identity-fixture" }).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)).toBeNull(); + await satisfiedConnectionIntentService(server.db).sweepPending(); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, intent.id)))[0].status).toBe("pending"); + expect(await server.db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, intent.id))).toEqual([]); + const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "github", providerAccountId: "identity-fixture", externalId: "1357" }).returning(); + const [link] = await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "pending" }).returning(); + for (const status of ["pending", "revoked", "expired"] as const) { + await server.db.update(chatIdentityLinks).set({ status }).where(eq(chatIdentityLinks.id, link.id)); + expect(await findSatisfiedToolConnection(server.db, task, intent), status).toBeNull(); + } + await server.db.update(chatIdentityLinks).set({ status: "linked", confirmedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id)); + await server.db.update(chatIdentityLinks).set({ paperclipUserId: randomUUID() }).where(eq(chatIdentityLinks.id, link.id)); + expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull(); + await server.db.update(chatIdentityLinks).set({ paperclipUserId: f.userId }).where(eq(chatIdentityLinks.id, link.id)); + await server.db.update(chatExternalPrincipals).set({ providerAccountId: "different-installation" }).where(eq(chatExternalPrincipals.id, principal.id)); + expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull(); + await server.db.update(chatExternalPrincipals).set({ providerAccountId: "identity-fixture" }).where(eq(chatExternalPrincipals.id, principal.id)); + expect((await findSatisfiedToolConnection(server.db, task, intent))?.id).toBe(created.body.connectionId); + expect(await findSatisfiedToolConnection(server.db, { ...task, status: "cancelled" }, intent)).toBeNull(); + await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended"); + expect(await findSatisfiedToolConnection(server.db, task, intent)).toBeNull(); + await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "active"); + await satisfiedConnectionIntentService(server.db).sweepPending(); + const [delivery] = await server.db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, intent.id)); + expect(delivery).toBeDefined(); + }); + + it("retains a restricted user's personal self-service without company management or spoofed ownership", async () => { + const f = await fixture(); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalType, "user"), eq(principalPermissionGrants.principalId, f.userId))); + provider = async url => { + if (url === "https://api.openai.com/v1/models") return Response.json({ data: [] }); + throw new Error("Unexpected AI fixture request"); + }; + const payload = { provider: "openai", method: "api_key", name: "Personal", ownership: "personal", apiKey: "personal-fixture", agentIds: [], allAgents: false }; + try { + expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { ...payload, ownership: "shared" })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { ...payload, responsibleUserId: randomUUID() })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, payload)).toMatchObject({ status: 201 }); + } finally { provider = null; } + }); + + it("rejects foreign personal metadata, missing responsible users and task-bridge keys", async () => { + const f = await fixture(); + const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Foreign personal fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + expect(created.status).toBe(201); + await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, created.body.id)); + expect(await f.request("GET", `/tool-connections/${created.body.id}`)).toMatchObject({ status: 403 }); + const listed = await f.request("GET", `/companies/${f.companyId}/tools/connections`); + expect(JSON.stringify(listed.body)).not.toContain("Foreign personal fixture"); + const [policy] = await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Private account rule", policyType: "block", selectors: { connectionIds: [created.body.id] } }).returning(); + expect(JSON.stringify((await f.request("GET", `/companies/${f.companyId}/tools/policies`)).body)).not.toContain(policy.id); + expect(await f.request("POST", `/companies/${f.companyId}/tools/policies`, { name: "Cannot edit foreign access", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: [created.body.id] } })).toMatchObject({ status: 403 }); + + await server.db.update(agentApiKeys).set({ responsibleUserId: null }).where(eq(agentApiKeys.id, f.key.id)); + expect((await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400); + const g = await fixture(); + await server.db.update(agentApiKeys).set({ scopeConfig: { kind: "task_bridge", issueId: g.issueId } }).where(eq(agentApiKeys.id, g.key.id)); + expect((await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).status).toBeGreaterThanOrEqual(400); + }); + + it("revalidates pending credential promotion after yielding without reviving cancelled work", async () => { + const f = await fixture(); + const context = await connectionManagementService(server.db).authorize({ type: "agent", companyId: f.companyId, + agentId: f.agentId, runId: f.runId, onBehalfOfUserId: f.userId }, "connections:configure", + { type: "connection_management", companyId: f.companyId, purpose: "ai", operation: "login" }); + const continuation = continuingConnectionManagement(context)!; + await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId)); + await expect(context.reauthorize()).rejects.toThrow(); + await expect(continuation.reauthorize()).resolves.toBeUndefined(); + await server.db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId)); + await expect(continuation.reauthorize()).rejects.toThrow(); + }); + + it("denies revoked agent grants and user authority, including shadow mode, without backfilling", async () => { + const f = await fixture(); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await f.request("GET", `/companies/${f.companyId}/tools/gallery`)).toMatchObject({ status: 403 }); + const g = await fixture(); + await accessService(server.db).ensureMembership(g.companyId, "user", g.userId, "operator", "suspended"); + expect(await g.request("GET", `/companies/${g.companyId}/tools/gallery`)).toMatchObject({ status: 403 }); + }); + + it("enforces grant scopes, company isolation, effective trust, and Ask/Plan work modes", async () => { + const f = await fixture(); + expect(await f.request("GET", `/companies/${f.foreignCompanyId}/tools/gallery`)).toMatchObject({ status: 403 }); + const owned = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Scoped fixture", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + expect(owned.status).toBe(201); + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [owned.body.id], purposes: ["tool"], operations: ["GET /tool-connections/:connectionId"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await f.request("GET", `/tool-connections/${owned.body.id}`)).toMatchObject({ status: 200 }); + expect(await f.request("PATCH", `/tool-connections/${owned.body.id}`, { name: "Beyond operation scope" })).toMatchObject({ status: 403 }); + await server.db.update(principalPermissionGrants).set({ scope: { provider: "slack" } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId })).toMatchObject({ status: 403 }); + const g = await fixture(); + for (const workMode of ["ask", "planning", "skill_test"]) { + await server.db.update(issues).set({ workMode }).where(eq(issues.id, g.issueId)); + expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden" }, g.jwt)).toMatchObject({ status: 403 }); + } + await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, g.issueId)); + await server.db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustPreset: "low_trust_review", lowTrustBoundary: { projectIds: [g.projectId] } } } }).where(eq(projects.id, g.projectId)); + expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden trust" }, g.jwt)).toMatchObject({ status: 403 }); + await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", projectIds: [g.projectId], rootIssueId: g.issueId } } } }).where(eq(agents.id, g.agentId)); + expect(await g.request("POST", `/companies/${g.companyId}/tools/profiles`, { name: "Forbidden agent trust" })).toMatchObject({ status: 403 }); + }); +}); diff --git a/server/src/__tests__/decision-models.test.ts b/server/src/__tests__/decision-models.test.ts index 6c593912087..02f3d0b69bf 100644 --- a/server/src/__tests__/decision-models.test.ts +++ b/server/src/__tests__/decision-models.test.ts @@ -300,10 +300,12 @@ describe("company decision service", () => { const url = `/api/companies/${f.companyId}/decision-model`; expect((await request(app).get(url)).body.canManage).toBe(true); expect((await request(app).get(`/api/companies/${other.companyId}/decision-model`)).status).toBe(403); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice"))); actor = { type: "board", userId: "alice", source: "session", companyIds: [f.companyId], memberships: [{ companyId: f.companyId, status: "active", membershipRole: "viewer" }] }; expect((await request(app).get(url)).body).toEqual({ canManage: false, settings: null, choices: [] }); expect((await request(app).put(url).send(f.config)).status).toBe(403); expect((await request(app).post(`${url}/test`).send({ actor: { type: "system" } })).status).toBe(403); + await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice"))); actor = f.context.actor; await f.service.configure(f.companyId, "alice", { ...f.config, enabled: false }); expect((await request(app).post(`${url}/test`).send({ state: "untrusted", actor: { type: "system" }, model: "untrusted" })).body).toEqual({ status: "unavailable", reason: "disabled" }); diff --git a/server/src/__tests__/email-address-check.test.ts b/server/src/__tests__/email-address-check.test.ts index 08a13e0e682..3716ffbfd8d 100644 --- a/server/src/__tests__/email-address-check.test.ts +++ b/server/src/__tests__/email-address-check.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import type { Db } from "@paperclipai/db"; +import { companyMemberships, instanceUserRoles, principalPermissionGrants, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { forbidden } from "../errors.js"; import { errorHandler } from "../middleware/error-handler.js"; @@ -18,7 +18,16 @@ function app(actor = { type: "board", source: "local_implicit", userId: "board" const instance = express(); instance.use(express.json()); instance.use((req, _res, next) => { req.actor = actor; next(); }); - instance.use("/api", emailRoutes({} as Db, {} as EmailChannelService)); + // Provider calls remain stubbed, while the shared guard resolves real-shaped + // resource and membership rows before those calls. + const db = { select: () => ({ from: (table: unknown) => { + const rows = table === toolConnections ? [{ id: connectionId, companyId, createdByUserId: "board", connectionPurpose: "tool", credentialPolicy: "shared", config: { provider: "agentmail" }, transportConfig: {} }] + : table === companyMemberships ? [{ companyId, principalType: "user", principalId: "member", status: "active", membershipRole: "member" }] + : []; + expect([toolConnections, toolConnectionInstalls, companyMemberships, instanceUserRoles, principalPermissionGrants]).toContain(table); + return { where: () => Object.assign(Promise.resolve(rows), { limit: async () => rows }) }; + } }) } as unknown as Db; + instance.use("/api", emailRoutes(db, {} as EmailChannelService)); instance.use(errorHandler); return instance; } @@ -59,7 +68,7 @@ describe("AgentMail address checks", () => { expect(mocks.credential).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); - it("does not allow agents to check addresses", async () => { + it("rejects agents without a responsible user", async () => { await request(app({ type: "agent", agentId: connectionId, companyId })).post(path).send(input).expect(403); expect(fetch).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/email-routes-provider-errors.test.ts b/server/src/__tests__/email-routes-provider-errors.test.ts index 742c0058ad7..a7bb01c77ce 100644 --- a/server/src/__tests__/email-routes-provider-errors.test.ts +++ b/server/src/__tests__/email-routes-provider-errors.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import type { Db } from "@paperclipai/db"; +import { agents, type Db } from "@paperclipai/db"; import { describe, expect, it, vi } from "vitest"; import { forbidden } from "../errors.js"; import { errorHandler } from "../middleware/error-handler.js"; @@ -26,7 +26,13 @@ function app(setup: () => Promise) { req.actor = { type: "board", source: "local_implicit", userId: "board" }; next(); }); - instance.use("/api", emailRoutes({} as Db, { setup, inspect: setup } as unknown as EmailChannelService)); + // This unit suite exercises provider error translation. The shared guard + // also resolves the setup's target agent before calling the stubbed service. + const db = { select: () => ({ from: (table: unknown) => { + expect(table).toBe(agents); + return { where: async () => [{ id: agentId, companyId }] }; + } }) } as unknown as Db; + instance.use("/api", emailRoutes(db, { setup, inspect: setup } as unknown as EmailChannelService)); instance.use(errorHandler); return instance; } diff --git a/server/src/__tests__/helpers/runner-api-server.ts b/server/src/__tests__/helpers/runner-api-server.ts index b85fa611d54..e59e1bb61fe 100644 --- a/server/src/__tests__/helpers/runner-api-server.ts +++ b/server/src/__tests__/helpers/runner-api-server.ts @@ -1,10 +1,13 @@ +import { hashPassword } from "better-auth/crypto"; +import { createBetterAuthInstance, createBetterAuthHandler, resolveBetterAuthSession } from "../../auth/better-auth.js"; +import type { Config } from "../../config.js"; import { createHash, randomUUID } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { eq, sql } from "drizzle-orm"; -import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db"; +import { agents, authUsers, authAccounts, companies, companyMemberships, createDb, applyPendingMigrations, closeRegisteredClients, 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,29 @@ 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; + databaseUrl?: string; } = {}) { const root = await mkdtemp(join(tmpdir(), "paperclip-api-eval-")); - const temporary = await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-"); + const temporary = options.databaseUrl + ? { connectionString: options.databaseUrl, cleanup: () => closeRegisteredClients(options.databaseUrl!) } + : await startEmbeddedPostgresTestDatabase("paperclip-api-eval-db-"); + if (options.databaseUrl) await applyPendingMigrations(options.databaseUrl); const db = createDb(temporary.connectionString); const storage = createStorageService(createLocalDiskStorageProvider(join(root, "storage"))); + const origin = options.authPublicBaseUrl ?? "http://127.0.0.1"; + const auth = options.humanSessions ? createBetterAuthInstance(db, { + deploymentMode: "authenticated", deploymentExposure: "private", authBaseUrlMode: "explicit", + authPublicBaseUrl: origin, authDisableSignUp: true, + } as Config, [origin]) : undefined; + const humanCookies = new Map(); const app = await createApp(db, { uiMode: "none", serverPort: 0, storageService: storage, deploymentMode: options.deploymentMode ?? "authenticated", deploymentExposure: "private", allowedHostnames: ["127.0.0.1"], bindHost: "127.0.0.1", authReady: true, + authPublicBaseUrl: options.authPublicBaseUrl, chatWebhookPublicBaseUrl: options.authPublicBaseUrl, + ...(auth ? { betterAuthHandler: createBetterAuthHandler(auth), resolveSession: (req: import("express").Request) => resolveBetterAuthSession(auth, req) } : {}), companyDeletionEnabled: false, instanceId: `eval-${randomUUID()}`, localPluginDir: join(root, "plugins"), managedPluginAutoInstall: [], decisionServiceOptions: { wakeOriginAgent: async () => undefined }, @@ -44,6 +61,21 @@ export async function startRunnerApiTestServer(options: { setupRunnerPrpWebSocketServer(http, { apiUrl }); return { db, root, apiUrl, storage, + async humanSession(userId: string) { + if (!auth) throw new Error("Enable humanSessions for this fixture"); + if (humanCookies.has(userId)) return humanCookies.get(userId)!; + const [user] = await db.select().from(authUsers).where(eq(authUsers.id, userId)); + const password = `fixture-${randomUUID()}`; + await db.insert(authAccounts).values({ id: randomUUID(), issuer: "local:credential", accountId: userId, + providerId: "credential", userId, password: await hashPassword(password), createdAt: new Date(), updatedAt: new Date() }); + const response = await auth.handler(new Request(`${origin}/api/auth/sign-in/email`, { + method: "POST", headers: { "Content-Type": "application/json", origin }, body: JSON.stringify({ email: user.email, password }), + })); + if (!response.ok) throw new Error(`Fixture sign-in failed (${response.status})`); + const cookie = response.headers.getSetCookie().map(cookie => cookie.split(";")[0]).join("; "); + humanCookies.set(userId, cookie); + return cookie; + }, async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean; conversation?: boolean; connectionScenario?: RunnerConnectionScenario; contextSnapshot?: Record; disableWakeOnDemand?: boolean } = {}) { if (options.connectionScenario !== undefined && !CONNECTION_SCENARIOS.includes(options.connectionScenario)) throw new Error(`Unknown connection eval scenario: ${String(options.connectionScenario)}`); // This DB is created inside this helper, never supplied by a caller. Paid diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index ff43e560394..48da426bab0 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -407,13 +407,13 @@ describe("openapi routes", () => { expect(operation.responses["503"]).toBeDefined(); }); - it("documents board-only pool management with revision-checked deletion", () => { + it("documents delegated pool management with revision-checked deletion", () => { const { spec } = loadSpecRoutes(); const pools = spec.paths["/api/companies/{companyId}/ai-connection-pools"]; const remove = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}"].delete; const inspection = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection"].get; for (const operation of [pools.get, pools.post, remove, inspection]) { - expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }, { AgentBearerAuth: [] }]); } expect(remove.requestBody.required).toBe(true); expect(remove.requestBody.content["application/json"].schema).toMatchObject({ @@ -616,7 +616,7 @@ describe("openapi routes", () => { ).not.toContain("sessionToken"); }); - it("publishes the complete board contract for chat channels", () => { + it("publishes delegated chat configuration and human-only confirmation boundaries", () => { const { spec } = loadSpecRoutes(); const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]; const operations = [ @@ -678,6 +678,13 @@ describe("openapi routes", () => { ], ] as const; + const humanOnly = new Set([ + "/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay", + "/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay", + "/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications", + "/api/chat-endpoints/{endpointId}/github/identity", "/api/chat-identity-links/preview", "/api/chat-identity-links/confirm", + "/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve", "/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve", + ]); for (const [method, routePath] of operations) { const operation = spec.paths[routePath]?.[method]; expect( @@ -686,10 +693,10 @@ describe("openapi routes", () => { ).toBeDefined(); expect( operation.security, - `${method.toUpperCase()} ${routePath} is board-only`, - ).toEqual(boardSecurity); + `${method.toUpperCase()} ${routePath} retains its actor boundary`, + ).toEqual(humanOnly.has(routePath) ? boardSecurity : [...boardSecurity, { AgentBearerAuth: [] }]); expect(operation["x-paperclip-authorization"]).toEqual({ - actor: "board", + actor: humanOnly.has(routePath) ? "board" : "board_or_agent", }); expect(operation.tags).toContain("chat-channels"); } @@ -747,6 +754,9 @@ describe("openapi routes", () => { ).toEqual(["current", "stale", "unverified"]); const setup = spec.paths["/api/chat-endpoints/{endpointId}/setup"].post; + const setupResponse = setup.responses["200"].content["application/json"].schema; + expect((setupResponse.oneOf ?? setupResponse.anyOf)[1].properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]); + expect(setup.description).toContain("without changing credentials"); expect( setup.requestBody.content["application/json"].schema.properties.action .enum, @@ -781,12 +791,16 @@ describe("openapi routes", () => { const setupSecret = spec.paths["/api/chat-endpoints/{endpointId}/setup-secret"].post; + const secretHandoff = setupSecret.responses["200"].content["application/json"].schema; + expect(secretHandoff.properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]); + expect(secretHandoff.properties).not.toHaveProperty("webhookSecret"); + expect(setupSecret.description).toContain("without rotating or reading"); expect( setupSecret.responses["201"].content["application/json"].schema, ).toMatchObject({ type: "object", additionalProperties: false, - required: ["webhookSecret"], + required: ["webhookSecret", "credentialRotationId"], }); expect(setupSecret.responses["409"]).toBeDefined(); expect(setupSecret.responses["422"]).toBeDefined(); @@ -1074,12 +1088,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; @@ -1087,6 +1101,10 @@ describe("openapi routes", () => { expect(input.required).not.toContain("ownerType"); const response = registration.responses["200"].content["application/json"].schema; expect(response.oneOf ?? response.anyOf).toHaveLength(2); + const appImport = spec.paths["/api/chat-endpoints/{endpointId}/github/app"].post; + expect(appImport.description).toContain("Only a human session"); + const appImportResponse = appImport.responses["200"].content["application/json"].schema; + expect(appImportResponse.oneOf ?? appImportResponse.anyOf).toHaveLength(2); }); it("documents GitHub recovery, draft receipts, paged repositories and old review lookups", () => { @@ -1095,21 +1113,22 @@ 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]); const receipt = spec.paths[root + "draft"].put.responses["200"].content["application/json"].schema; expect(receipt.required).toEqual(["saved"]); expect(receipt.properties).not.toHaveProperty("state"); - const app = spec.paths[root + "app"].post.requestBody.content["application/json"].schema; + const appInput = spec.paths[root + "app"].post.requestBody.content["application/json"].schema; + const app = (appInput.oneOf ?? appInput.anyOf).find((variant: { properties?: { appId?: unknown } }) => variant.properties?.appId); expect(app.properties.clientId).toBeDefined(); expect(app.properties.clientSecret).toBeDefined(); expect(app.required).not.toContain("clientId"); const page = spec.paths[root + "repositories"].get; expect(page.parameters.map((param: { name: string }) => param.name)).toEqual(expect.arrayContaining(["endpointId", "offset", "limit", "search"])); expect(page.responses["200"].content["application/json"].schema.properties).toHaveProperty("totalCount"); - expect(spec.paths[root + "repositories/access"].put["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(spec.paths[root + "repositories/access"].put["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" }); const review = spec.paths[root + "reviews/{reviewId}"].get; expect(review.parameters.map((param: { name: string }) => param.name)).toEqual(["endpointId", "reviewId"]); expect(review.responses["404"]).toBeDefined(); diff --git a/server/src/__tests__/task-connector-read-routes.test.ts b/server/src/__tests__/task-connector-read-routes.test.ts index f903e4693c7..74fdceb855a 100644 --- a/server/src/__tests__/task-connector-read-routes.test.ts +++ b/server/src/__tests__/task-connector-read-routes.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import type { Db } from "@paperclipai/db"; +import { companyMemberships, instanceUserRoles, issues, type Db } from "@paperclipai/db"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { errorHandler } from "../middleware/error-handler.js"; import { chatChannelRoutes } from "../routes/chat-channels.js"; @@ -25,11 +25,21 @@ function app(actor = boardActor) { req.actor = actor; next(); }); - instance.use("/api", chatChannelRoutes({} as Db, { + // Supply the resource and active membership that the management read guard + // now resolves. Connector service behavior stays isolated in this unit suite. + const db = { select: () => ({ from: (table: unknown) => { + const rows = table === issues ? [{ id: issueId, companyId, projectId: null }] + : table === companyMemberships ? [{ companyId, principalType: "user", principalId: boardActor.userId, membershipRole: "operator", status: "active" }] + : table === instanceUserRoles ? [] + : null; + if (!rows) throw new Error("Unexpected resource query in task read fixture"); + return { where: () => Object.assign(Promise.resolve(rows), { limit: async () => rows }) }; + } }) } as unknown as Db; + instance.use("/api", chatChannelRoutes(db, { service: chat as unknown as ChatChannelService, heartbeat: { wakeup: vi.fn() }, })); - instance.use("/api", emailRoutes({} as Db, email as unknown as EmailChannelService)); + instance.use("/api", emailRoutes(db, email as unknown as EmailChannelService)); instance.use(errorHandler); return instance; } @@ -69,7 +79,7 @@ describe("task connector read routes", () => { it.each([issueId, `chat:${issueId}`])("retains authentication checks for %s", async (id) => { const server = app({ type: "none", source: "none" }); - await request(server).get(`/api/issues/${id}/chat-binding`).expect(403); + await request(server).get(`/api/issues/${id}/chat-binding`).expect(401); await request(server).get(`/api/companies/${companyId}/email/tasks/${id}`).expect(401); expect(chat.getIssueBinding).not.toHaveBeenCalled(); expect(email.authorizeRead).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/tool-access-policy-service.test.ts b/server/src/__tests__/tool-access-policy-service.test.ts index 525f05db57d..e74fa60b9c9 100644 --- a/server/src/__tests__/tool-access-policy-service.test.ts +++ b/server/src/__tests__/tool-access-policy-service.test.ts @@ -34,7 +34,8 @@ import { toolAccessService } from "../services/tool-access.js"; import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js"; import { canonicalToolArguments, signToolArguments } from "../services/tool-content-guards.js"; -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const externalTestDatabaseUrl = process.env.PAPERCLIP_TOOL_POLICY_TEST_DATABASE_URL; +const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; function legacySideEffectKey(invocation: typeof toolInvocations.$inferSelect, argumentsHash: string) { @@ -215,8 +216,8 @@ describeEmbeddedPostgres("tool access policy service", () => { let tempDb: Awaited> | null = null; beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-access-policy-"); - db = createDb(tempDb.connectionString); + if (!externalTestDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-access-policy-"); + db = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString); }, 20_000); afterEach(async () => { @@ -244,6 +245,7 @@ describeEmbeddedPostgres("tool access policy service", () => { }); afterAll(async () => { + await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); }); @@ -1105,7 +1107,8 @@ describeEmbeddedPostgres("tool access policy service", () => { .where(eq(toolInvocations.id, invocation.id)); expect(await svc.decide(input)).toMatchObject({ decision: "rate_limited" }); expect(invocation.policyExplanation?.consumedRateAdmissions).toEqual([ - { policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/) }, + { policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/), + bucketKey: `${ratePolicy.id}:agent:${input.actor.agentId}|tool:send_email` }, ]); const counters = await db.select().from(toolRateLimitCounters); expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ allowed: true }); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 38c49754c4b..6caafb2cb03 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -392,7 +392,15 @@ function createRouteApp( ) { const app = express(); app.use(express.json()); - app.use((req, _res, next) => { + app.use(async (req, _res, next) => { + // Session fixtures must persist the membership normally loaded by authentication. + // Keep existing rows unchanged so explicit restrictions and revocations survive. + if (actor?.type === "board" && actor.userId) { + for (const membership of actor.memberships ?? []) await db.insert(companyMemberships).values({ + companyId: membership.companyId, principalType: "user", principalId: actor.userId, + membershipRole: membership.membershipRole, status: membership.status, + }).onConflictDoNothing(); + } req.actor = actor ?? { type: "board", userId: "board-user", @@ -1744,7 +1752,7 @@ describeEmbeddedPostgres("tool access service", () => { ).post(`/api/tool-connections/${connection.id}/health-check`); expect(response.status).toBe(403); - expect(response.body.error).toContain("need access to this connection"); + expect(response.body.error).toContain("Missing permission: tools:manage_connections"); }); it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => { @@ -4849,7 +4857,7 @@ describeEmbeddedPostgres("tool access service", () => { }); }); - it("keeps duplicate, delete, and new-tools profile routes board-only and viewer-safe", async () => { + it("keeps duplicate, delete, and new-tools profile routes permission-gated and viewer-safe", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) @@ -20177,7 +20185,7 @@ describeEmbeddedPostgres("tool access service", () => { .send({ name: "Member edit" }); expect(denied.status).toBe(403); expect(denied.body.error).toContain( - "connection creator or a connection manager", + "Missing permission: tools:manage_connections", ); await request(createRouteApp(db, creator)) diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 32dcd49bef7..a1f0b553d17 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -65,7 +65,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const externalTestDatabaseUrl = process.env.PAPERCLIP_TOOL_GATEWAY_TEST_DATABASE_URL; +const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const testToolActionSigningSecret = "test-tool-action-signing-secret"; @@ -553,8 +554,8 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { let tempDb: Awaited> | null = null; beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-"); - db = createDb(tempDb.connectionString); + if (!externalTestDatabaseUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-"); + db = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString); }, 90_000); afterEach(async () => { @@ -592,6 +593,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { }); afterAll(async () => { + await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); }); @@ -4987,7 +4989,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 +5006,25 @@ rl.on("line", (line) => { .get("/api/tool-gateway/runtime-slots") .query({ companyId: company.id }); expect(list.status).toBe(403); - expect(list.body.error).toBe("Board access required"); + const stop = await request(app) .post("/api/tool-gateway/runtime-slots/slot-1/stop") .send({ companyId: company.id }); expect(stop.status).toBe(403); - expect(stop.body.error).toBe("Board access required"); + const restart = await request(app) .post("/api/tool-gateway/runtime-slots/slot-1/restart") .send({ companyId: company.id }); expect(restart.status).toBe(403); - expect(restart.body.error).toBe("Board access required"); + const audit = await request(app) .get("/api/tool-gateway/audit") .query({ companyId: company.id }); expect(audit.status).toBe(403); - expect(audit.body.error).toBe("Board access required"); + }); it("allows board runtime control and audit reads through explicit board permissions", async () => { diff --git a/server/src/app.ts b/server/src/app.ts index c16625b8f0e..d7d0e4ee9ca 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -704,16 +704,16 @@ export async function createApp( app.get("/api/chat-github/manifest/callback", async (req, res) => { res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); - const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? "")); + const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor); res.redirect(303, redirect); }); app.get("/api/chat-github/cloud/callback", async (req, res) => { res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); - res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined)); + res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined, req.actor)); }); app.get("/api/chat-github/identity/callback", async (req, res) => { res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); - res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? ""))); + res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor)); }); const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null; const bundledCatalogRoot = diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 81d8ab77974..3a2d11e574d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,6 +1,8 @@ import { latestFailedRunsByAgent } from "@paperclipai/shared"; 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"; @@ -586,6 +588,7 @@ export function agentRoutes( const KNOWN_INSTRUCTIONS_BUNDLE_KEY_SET: ReadonlySet = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS); const router = Router(); + const managementGuard = connectionManagementGuard(db, "ai"); const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) }); const svcLifecycle = createAgentLifecycle(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) }); const access = accessService(db); @@ -889,7 +892,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; } @@ -1134,7 +1137,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; } @@ -1423,6 +1426,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({ @@ -1445,33 +1454,13 @@ export function agentRoutes( if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } - // The single owner-authorization helper for the three adapter login routes. It - // requires a board actor, company access, and the same configuration - // permission as the adapter Test route (`agents:create`). It returns the - // immutable owner identifier: the board user that starts, reads, or cancels the - // session. The start route persists this identifier; the status and cancel - // routes compare it to the session owner and return 404 on a mismatch, so a - // non-owner cannot enumerate a session. async function assertCanManageAdapterLogin( req: Request, companyId: string, ): Promise { - assertBoard(req); - assertCompanyAccess(req, companyId); - const decision = await access.decide({ - actor: req.actor, - action: "agents:create", - resource: { type: "company", companyId }, - }); - if (!decision.allowed) { - throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); - } - const userId = req.actor.userId; - if (!userId) { - throw forbidden( - "A board user identity is required to manage an adapter login session.", - ); - } + await assertBoardCanManageAgentsForCompany(req, companyId); + const userId = connectionRequestUserId(req); + if (!userId) throw forbidden("A responsible user identity is required to manage an adapter login session."); return userId; } @@ -3050,7 +3039,7 @@ export function agentRoutes( const type = assertKnownAdapterType(req.params.type as string); const poolId = asNonEmptyString(req.query.poolId); if (poolId) { - const pool = (await aiConnectionRouterService(db).selectable(companyId, getActorInfo(req).actorId)).find(pool => pool.id === poolId); + const pool = (await aiConnectionRouterService(db).selectable(companyId, responsibleUserForAiRequest(req) ?? "")).find(pool => pool.id === poolId); if (!pool) throw unprocessable("Enable an accessible AI connection pool"); const catalog = new Map(); for (const member of pool.members) { @@ -3593,7 +3582,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; @@ -3625,6 +3614,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"); @@ -3639,6 +3629,7 @@ export function agentRoutes( startedByUserId, ttlSeconds: data.ttlSeconds, aiConnection: data.aiConnection, + managementContext: continuingConnectionManagement(connectionRequestContext(req)), signal: controller.signal, }); } catch (error) { @@ -3661,7 +3652,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) }); }, ); @@ -3673,7 +3664,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; @@ -3697,7 +3688,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; @@ -3718,7 +3709,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; @@ -5602,6 +5593,13 @@ export function agentRoutes( if (nextAiBinding) { await assertCanConfigureAgentSetup(req, existing); const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection); + if (changed && req.actor.type === "agent") { + await connectionManagementService(db).authorize(req.actor, "connections:profiles", { + type: "connection_management", companyId: existing.companyId, purpose: "ai", targetAgentId: existing.id, + connectionId: "connectionId" in nextAiBinding ? nextAiBinding.connectionId : undefined, + operation: "agent.ai-binding.update", + }); + } const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record; if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model"); const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot" @@ -6447,11 +6445,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; }; /** @@ -6617,7 +6614,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 @@ -6683,6 +6686,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"); @@ -6696,6 +6700,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 @@ -6714,7 +6719,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); } @@ -6728,7 +6733,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; @@ -6755,7 +6760,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; @@ -6774,7 +6779,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; @@ -6808,6 +6813,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; @@ -6843,7 +6849,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; @@ -6865,7 +6871,7 @@ export function agentRoutes( } }); - router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/cancel", async (req, res) => { + router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/cancel", managementGuard("connections:configure", "POST /companies/:companyId/setup-token-login-sessions/:sessionId/cancel", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; const ownerUserId = resolveCompanySessionOwner(req, companyId, res); if (ownerUserId === null) return; diff --git a/server/src/routes/ai-connections.ts b/server/src/routes/ai-connections.ts index 639fc6b2320..98d19cd8078 100644 --- a/server/src/routes/ai-connections.ts +++ b/server/src/routes/ai-connections.ts @@ -1,3 +1,5 @@ +import { connectionManagementGuard, connectionRequestActor, connectionRequestContext, connectionRequestUserId, aiLoginUserAction, filterConnectionManagementRows } from "./connection-management-authz.js"; +import { connectionManagementService } from "../services/connection-management.js"; import { validateAiApiKey } from "../services/ai-api-key-test.js"; export { validateAiApiKey } from "../services/ai-api-key-test.js"; import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js"; @@ -26,7 +28,7 @@ import { type AiConnectionList, aiConnectionPoolConfigSchema, } from "@paperclipai/shared"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js"; import { forbidden, notFound, unprocessable } from "../errors.js"; import { accessService } from "../services/access.js"; import { logActivity } from "../services/activity-log.js"; @@ -44,11 +46,11 @@ export function responsibleUserForAiRequest(req: Request): string | null { } export async function canManageAiConnections(db: Db, req: Request, companyId: string): Promise { - const membership = req.actor.memberships?.find((m) => m.companyId === companyId && m.status === "active"); - if (membership?.membershipRole === "viewer") return false; - return req.actor.source === "local_implicit" || Boolean(req.actor.isInstanceAdmin) - || membership?.membershipRole === "owner" || membership?.membershipRole === "admin" - || await accessService(db).hasPermission(companyId, "user", getActorInfo(req).actorId, "tools:manage_connections"); + const memberships = req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships; + if (memberships?.some(m => m.companyId === companyId && m.status === "active" && m.membershipRole === "viewer")) return false; + 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 +62,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 +91,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 +108,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 +122,27 @@ export async function assertAiConnectionCreateAccess( return userId; } -/** Creating an agent may install a shared connection only with the existing - * connection-configure authority. An agent actor cannot grant itself access. */ +/** Installation is a connection/profile change, bounded by the creator's existing authority. */ export async function canInstallSharedAiConnectionForNewAgent( db: Db, req: Request, companyId: string, binding: AiConnectionBinding, ): Promise { - if (req.actor.type !== "board" || binding.mode !== "shared") return false; + if (binding.mode !== "shared" || (req.actor.type === "agent" && (!req.actor.agentId || !req.actor.companyId))) return false; assertCompanyAccess(req, companyId); - const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active"); - if (member?.membershipRole === "viewer") return false; - const userId = getActorInfo(req).actorId; - const [connection] = await db.select({ creator: toolConnections.createdByUserId }) - .from(toolConnections).where(and(eq(toolConnections.companyId, companyId), - eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai"))); - if (!connection) return false; - return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true || - connection.creator === userId || await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections"); + if (req.actor.type === "board") { + const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active"); + if (member?.membershipRole === "viewer") return false; + const userId = getActorInfo(req).actorId; + const [connection] = await db.select({ creator: toolConnections.createdByUserId }).from(toolConnections).where(and( + eq(toolConnections.companyId, companyId), eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai"), + )); + return Boolean(connection && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin || connection.creator === userId || + await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections"))); + } + const resource = { type: "connection_management" as const, companyId, connectionId: binding.connectionId, + purpose: "ai" as const, operation: "ai_binding.install" }; + const management = connectionManagementService(db); + return (await management.capability(req.actor, "connections:configure", resource)).allowed && + (await management.capability(req.actor, "connections:profiles", resource)).allowed; } @@ -143,53 +151,49 @@ export function aiConnectionRoutes(db: Db, options: Parameters { + router.get("/companies/:companyId/ai-connection-pools", managementGuard("connections:read", "GET /companies/:companyId/ai-connection-pools", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; - assertBoard(req); assertCompanyAccess(req, companyId); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required"); res.setHeader("Cache-Control", "no-store"); - res.json(await pools.list(companyId)); + res.json(await filterConnectionManagementRows(db, req, "connections:read", await pools.list(companyId), pool => ({ connectionId: pool.id }))); }); - router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", async (req, res) => { + router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", managementGuard("connections:read", "GET /companies/:companyId/ai-connection-pools/:poolId/inspection", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; - assertBoard(req); assertCompanyAccess(req, companyId); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required"); res.setHeader("Cache-Control", "no-store"); - res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), getActorInfo(req).actorId)); + res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), responsibleUserForAiRequest(req)!)); }); - router.post("/companies/:companyId/ai-connection-pools", async (req, res) => { + router.post("/companies/:companyId/ai-connection-pools", managementGuard("connections:configure", "POST /companies/:companyId/ai-connection-pools", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; - assertBoard(req); assertCompanyAccess(req, companyId); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required"); const body = z.object({ pluginKey: z.string().min(1).max(160), id: z.string().uuid().optional(), expectedRevision: z.number().int().positive().optional(), config: aiConnectionPoolConfigSchema }).strict().parse(req.body); - res.json(await pools.save(body.pluginKey, { ...body, companyId }, getActorInfo(req).actorId)); + res.json(await pools.save(body.pluginKey, { ...body, companyId }, responsibleUserForAiRequest(req)!, connectionRequestContext(req))); }); - router.delete("/companies/:companyId/ai-connection-pools/:poolId", async (req, res) => { + router.delete("/companies/:companyId/ai-connection-pools/:poolId", managementGuard("connections:configure", "DELETE /companies/:companyId/ai-connection-pools/:poolId", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; - assertBoard(req); assertCompanyAccess(req, companyId); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required"); const poolId = z.string().uuid().parse(req.params.poolId); const { expectedRevision } = z.object({ expectedRevision: z.number().int().positive() }).strict().parse(req.body); - res.json(await pools.remove(companyId, poolId, expectedRevision, getActorInfo(req).actorId)); + res.json(await pools.remove(companyId, poolId, expectedRevision, responsibleUserForAiRequest(req)!, connectionRequestContext(req))); }); - function assertLocalOperator(req: Request) { - assertBoard(req); - assertCompanyAccess(req, req.params.companyId as string); - if (req.actor.source !== "local_implicit") - throw forbidden("Only the local operator can connect this machine's CLI account."); - } - router.post("/companies/:companyId/ai-connections/local/attempts", validate(localAiLoginStartSchema), async (req, res) => { + router.post("/companies/:companyId/ai-connections/local/attempts", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local/attempts", { personal: true }), validate(localAiLoginStartSchema), async (req, res) => { const companyId = req.params.companyId as string; const { restart, ...intent } = localAiLoginStartSchema.parse(req.body); assertLocalLoginAvailable(); const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent); res.setHeader("Cache-Control", "no-store"); - res.status(201).json(await localLogin.start(companyId, userId, intent, restart)); + const attempt = await localLogin.start(companyId, userId, intent, restart, connectionRequestContext(req)); + res.status(201).json({ ...attempt, ...await aiLoginUserAction(db, req, companyId, attempt.sessionId) }); }); - router.post("/companies/:companyId/ai-connections/local/check", validate(localAiConnectionSchema), async (req, res) => { + router.post("/companies/:companyId/ai-connections/local/check", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local/check", { personal: true }), validate(localAiConnectionSchema), async (req, res) => { const companyId = req.params.companyId as string; const { localSessionId, ...intent } = localAiConnectionSchema.parse(req.body); assertLocalLoginAvailable(); @@ -197,11 +201,11 @@ export function aiConnectionRoutes(db: Db, options: Parameters { - assertBoard(req); + router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", managementGuard("connections:configure", "DELETE /companies/:companyId/ai-connections/local/attempts/:sessionId", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); assertCompanyAccess(req, req.params.companyId as string); const id = z.string().uuid().parse(req.params.sessionId); - await localLogin.cancel(req.params.companyId as string, getActorInfo(req).actorId, id); + await localLogin.cancel(req.params.companyId as string, responsibleUserForAiRequest(req)!, id, connectionRequestContext(req)); res.json({ ok: true }); }); router.post("/companies/:companyId/ai-connections/local/attempts/:sessionId/code", validate(z.object({ browserCode: browserCodeSchema })), async (req, res) => { @@ -214,44 +218,44 @@ export function aiConnectionRoutes(db: Db, options: Parameters { + router.get("/companies/:companyId/ai-connections", managementGuard("connections:read", "GET /companies/:companyId/ai-connections", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); - const currentUserId = getActorInfo(req).actorId; + const currentUserId = responsibleUserForAiRequest(req)!; res.setHeader("Cache-Control", "no-store"); const agentId = req.query.agentId; if (agentId !== undefined && !z.string().uuid().safeParse(agentId).success) throw unprocessable("Invalid agent ID"); res.json({ currentUserId, - pools: await pools.selectable(companyId, currentUserId), + pools: await filterConnectionManagementRows(db, req, "connections:read", await pools.selectable(companyId, currentUserId), pool => ({ connectionId: pool.id })), canManageConnections: await canManageAiConnections(db, req, companyId), - connections: await service.list( + connections: await filterConnectionManagementRows(db, req, "connections:read", await service.list( companyId, currentUserId, agentId as string | undefined, - ), + ), account => ({ connectionId: account.id })), } 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 +269,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters account.id === connection.id)) + if (!connection || !(await service.list(companyId, responsibleUserForAiRequest(req)!)).some(account => account.id === connection.id)) throw notFound("AI connection not found"); res.setHeader("Cache-Control", "no-store"); res.json( @@ -289,7 +293,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters { const companyId = req.params.companyId as string; @@ -316,12 +320,13 @@ export function aiConnectionRoutes(db: Db, options: Parameters { const companyId = req.params.companyId as string; @@ -331,18 +336,18 @@ export function aiConnectionRoutes(db: Db, options: Parameters { const companyId = req.params.companyId as string; - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); - const userId = getActorInfo(req).actorId; + const userId = responsibleUserForAiRequest(req)!; if ( - req.actor.memberships?.some( + (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.some( (m) => m.companyId === companyId && m.membershipRole === "viewer", ) ) @@ -352,8 +357,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters { + const [session] = await db.select().from(adapterAuthSessions).where(and( + eq(adapterAuthSessions.companyId, req.params.companyId as string), + eq(adapterAuthSessions.publicSessionId, req.params.sessionId as string), + eq(adapterAuthSessions.startedByUserId, responsibleUserForAiRequest(req)!), + )); + if (!session?.aiConnection || !session.expiresAt || session.expiresAt.getTime() <= Date.now() || + ["cancelled", "failed", "timed_out"].includes(session.status)) throw notFound("Saved sign-in is no longer available"); + res.setHeader("Cache-Control", "no-store"); + res.json({ sessionId: session.publicSessionId, intent: session.aiConnection, environmentId: session.environmentId, + status: session.status, connectionId: session.connectionId, grantId: session.connectionGrantId }); + }); router.get( - "/companies/:companyId/ai-connections/login/:sessionId", + "/companies/:companyId/ai-connections/login/:sessionId", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/login/:sessionId", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); const [session] = await db .select({ @@ -376,7 +392,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters { - assertBoard(req); + assertBoardOrAgent(req); const endpoint = await getAccessibleResource( req, res, @@ -92,10 +100,27 @@ 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, kind: ConnectionRequiredUserAction["kind"] = "authenticate", options: { requireCredentialRotation?: boolean; credentialFields?: 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 action = options.requireCredentialRotation ? "rotate_credentials" as const : kind === "enter_credentials" || kind === "confirm_identity" ? kind : undefined; + const handoff = 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, { action, credentialFields: options.credentialFields }) + : null; + const interactionId = handoff?.interactionId ?? null; + const previousRotationId = handoff ? handoff.previousRotationId : chatCredentialRotationId(endpoint.setup); + return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId, + ...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId }, + savedChatSetupPath(company!.prefix, endpoint, action, previousRotationId, { fields: options.credentialFields, requireAll: !!options.credentialFields }), reason, kind), + }; + } + async function assertIdentityLinkAccess(req: ExpressRequest) { assertBoard(req); const userId = actorUserId(req); @@ -123,11 +148,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 +182,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 +201,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 +243,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 +259,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 +285,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 +307,173 @@ 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)); + const review = await github.review(endpointId(req), reviewId.data); + const visible = await filterConnectionManagementRows(db, req, "connections:read", [review], row => ({ issueId: row.issueId })); + if (!visible.length) throw notFound("This review was not found in this connection"); + res.json(visible[0]); }); - 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))); + res.json(await filterConnectionManagementRows(db, req, "connections:read", await github.reviews(endpointId(req)), row => ({ issueId: row.issueId }))); }); - 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.", "confirm_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"), async (req, res, next) => { + if (req.actor.type !== "agent") { next(); return; } if (!(await assertEndpointManagementAccess(req, res))) return; - res.json(await service.storeGitHubApp(endpointId(req), githubUser(req), req.body)); - }); - router.post("/chat-endpoints/:endpointId/github/repositories/refresh", async (req, res) => { + const endpoint = await service.get(endpointId(req)); + if (endpoint.provider !== "github") throw notFound("GitHub bot not found"); + if (["archived", "active", "paused"].includes(endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot"); + res.set("Cache-Control", "no-store"); + res.json(await setupHandoff(req, "The responsible user must enter the GitHub App credentials and webhook signing secret.", "enter_credentials")); + }, 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.refreshGitHubRepositories(endpointId(req), githubUser(req))); + res.json(await service.storeGitHubApp(endpointId(req), githubUser(req), req.body, "existing_app", connectionRequestContext(req))); }); - router.get("/chat-endpoints/:endpointId/github/repositories", 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), connectionRequestContext(req))); + }); + 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)), - ); + res.set("Cache-Control", "no-store"); + try { + res.json(await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req))); + } catch (error) { + if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403 + || !error.details || typeof error.details !== "object" || !("code" in error.details) + || error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error; + const credentialFields = "credentialFields" in error.details && Array.isArray(error.details.credentialFields) + ? error.details.credentialFields.filter((field): field is string => typeof field === "string") : undefined; + res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials", { credentialFields })); + } }, ); - 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"); + if (req.actor.type === "agent") { + if ((await service.get(endpointId(req))).provider !== "github") throw badRequest("Setup secret generation is only available for GitHub"); + res.json(await setupHandoff(req, "The responsible user must rotate and enter the webhook signing secret in GitHub.", "enter_credentials", { requireCredentialRotation: true })); + return; + } 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 +481,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 +496,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 +525,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 +574,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 +603,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 +615,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 +633,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 +650,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 +684,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 +708,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 +726,9 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { "Issue not found", ); if (!endpoint) return; + await connectionManagementService(db).authorize(req.actor, "connections:read", { + ...connectionRequestContext(req)!.resource, connectionId: endpoint.connectionId, targetAgentId: endpoint.assignedAgentId, + }, { mutation: false }); } res.json(binding); }); diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts new file mode 100644 index 00000000000..d747ebfb271 --- /dev/null +++ b/server/src/routes/connection-management-authz.ts @@ -0,0 +1,473 @@ +import { isUuidLike, type ConnectionRequiredUserAction } from "@paperclipai/shared"; +import type { Request, RequestHandler } from "express"; +import { and, eq, or, sql } 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, companyMemberships } from "@paperclipai/db"; +import { badRequest, forbidden, notFound, HttpError } from "../errors.js"; +import { assertAuthenticated, assertCompanyAccess, hasCompanyAccess, getActorInfo } from "./authz.js"; +import { connectionManagementService, responsibleConnectionUser, type ConnectionManagementContext } from "../services/connection-management.js"; +import type { ConnectionManagementAction, ConnectionManagementResource } from "../services/connection-management-policy.js"; + +const contexts = new WeakMap(); +export function connectionRequestContext(req: Request) { return contexts.get(req); } +export function connectionRequestUserId(req: Request) { return responsibleConnectionUser(req.actor); } +export function connectionRequestActor(req: Request) { + return { ...getActorInfo(req), ...contexts.get(req)?.audit, responsibleUserId: connectionRequestUserId(req), managementContext: contexts.get(req) }; +} + + +export function requiredConnectionUserAction( + req: Request, + resource: ConnectionRequiredUserAction["resource"], + setupPath: string, + reason: string, + kind: ConnectionRequiredUserAction["kind"] = "authenticate", +): { requiredUserAction?: ConnectionRequiredUserAction } { + if (req.actor.type !== "agent") return {}; + const userId = connectionRequestUserId(req); + if (!userId) throw forbidden("A responsible user is required for authentication"); + return { requiredUserAction: { kind, userId, resource, setupPath, reason, + continuation: "Inspect this saved setup after the addressed user completes the required step." } }; +} + +function assertBoundedManagementSelectors(body: Record) { + if (!body || typeof body !== "object" || Array.isArray(body)) throw badRequest("Resource selectors must be objects"); + const arrays = ["policyIds", "credentialRefs", "credentialSecretRefs", "installs", "bindings", "entries", "agentIds"]; + const identifiers = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "subjectAgentId", "assignedAgentId", "secretId", "targetId"]; + const pending: Record[] = [body]; + const unique = new Set(); + let references = 0; + for (let index = 0; index < pending.length; index++) { + const selected = pending[index]; + for (const key of identifiers) { + const value = selected[key]; + if (typeof value === "string") unique.add(`${key}:${value}`); + } + const keys = new Set([...arrays, ...identifiers.map(key => `${key}s`)]); + for (const key of keys) { + const values = selected[key]; + if (values === undefined || values === null) continue; + if (!Array.isArray(values)) throw badRequest(`${key} must be an array`); + references += values.length; + if (references > 2048) throw badRequest("Connection management requests support at most 2048 resource references"); + for (const value of values) { + if (typeof value === "string") unique.add(`${key}:${value}`); + else if (value && typeof value === "object" && !Array.isArray(value)) pending.push(value as Record); + } + } + for (const key of ["selectors", "access", "binding"]) { + const value = selected[key]; + if (value && typeof value === "object" && !Array.isArray(value)) pending.push(value as Record); + } + if (selected === body && body.config && typeof body.config === "object" && !Array.isArray(body.config)) { + const members = (body.config as Record).members; + if (members !== undefined) { + if (!Array.isArray(members)) throw badRequest("Pool members must be an array"); + references += members.length; + if (references > 2048) throw badRequest("Connection management requests support at most 2048 resource references"); + for (const member of members) if (member && typeof member === "object" && !Array.isArray(member)) pending.push(member as Record); + } + } + if (unique.size > 1024 || pending.length > 2049) throw badRequest("Connection management requests support at most 1024 unique resource selectors"); + } +} + +/** 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) => { + const rawBody = req.body ?? {}; + const authorizeRequest = async (afterHumanAction?: boolean): Promise => { + const authorize: typeof management.authorize = (actor, requestedAction, resolved, checkOptions) => + management.authorize(actor, requestedAction, resolved, { ...checkOptions, afterHumanAction }); + 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 body = purpose === "ai" && rawBody.aiConnection ? { ...rawBody, ...rawBody.aiConnection } : rawBody; + assertBoundedManagementSelectors(body); + 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 applicationProvider: string | null = null; + let companyId = typeof req.params.companyId === "string" ? req.params.companyId : null; + const earlyCompanyId = companyId ?? req.actor.companyId; + if (earlyCompanyId) { + assertCompanyAccess(req, earlyCompanyId); + if (req.actor.type === "agent") { + const memberships = await db.select({ principalType: companyMemberships.principalType, principalId: companyMemberships.principalId }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, earlyCompanyId), eq(companyMemberships.status, "active"), + or(and(eq(companyMemberships.principalType, "agent"), eq(companyMemberships.principalId, req.actor.agentId!)), + and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId!))), + )); + if (!memberships.some(member => member.principalType === "agent") || !memberships.some(member => member.principalType === "user")) throw forbidden("Connection management requires active agent and responsible-user memberships"); + } + } + const resource: ConnectionManagementResource = { type: "connection_management", companyId: companyId ?? "", purpose, operation, + readOnly: req.method === "GET" && action !== "connections:gateway" && (action !== "connections:audit" || purpose === "chat") }; + const targets = new Set(); + const extraResources: ConnectionManagementResource[] = []; + const bindingTargets: { targetType: string; targetId: string }[] = []; + const selectors: Record[] = []; + const policyAudiences: Record[] = []; + if (Object.hasOwn(body, "selectors")) policyAudiences.push(body.selectors ?? {}); + const selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds", "routineId", "routineIds", "selectorType", "toolName", "riskLevel", "defaultAction"]; + const seenSelectors = new Set(); + const addSelector = (selected: Record) => { + assertBoundedManagementSelectors(selected); + const key = JSON.stringify(selectorKeys.map(name => selected[name])); + if (seenSelectors.has(key)) return; + if (selectors.length >= 1024) throw badRequest("Connection management request spans too many resources"); + seenSelectors.add(key); selectors.push(selected); + }; + const addSelectors = (selected: Record[]) => selected.forEach(addSelector); + addSelectors([body, body.access && typeof body.access === "object" ? body.access : {}, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])]); + const lookupCache = new Map>(); + const lookup = (key: string, read: () => PromiseLike): Promise => { + let pending = lookupCache.get(key); + if (!pending) { pending = Promise.resolve(read()); lookupCache.set(key, pending); } + return pending as Promise; + }; + const bindCompany = (row: { companyId: string } | undefined) => { + if (!row || (req.actor.type === "agent" && row.companyId !== req.actor.companyId) || (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 new Set(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); addSelector(policy.selectors); policyAudiences.push(policy.selectors); + } + if (req.params.policyId) { + const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(req.params.policyId))); + bindCompany(policy); addSelector(policy.selectors); policyAudiences.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 lookup(`secret:${String(ref.secretId)}`, () => 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; + addSelector({ ...entry, ...body }); + } + 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); + applicationProvider = text(application.metadata.sourceTemplateKey); + resource.provider ??= applicationProvider; + if (req.method !== "GET") { + if (application.ownerAgentId) targets.add(application.ownerAgentId); + const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id)).limit(1025); + 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.config.ai as Record | undefined)?.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)); + addSelectors(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 (connectionId && operation === "POST /companies/:companyId/tools/apps/:connectionId/finish") { + const [profile] = await db.select({ id: toolProfiles.id }).from(toolProfiles).where(and( + eq(toolProfiles.companyId, companyId!), eq(toolProfiles.profileKey, `app:${resource.connectionId}`), + )); + profileId = profile?.id ?? profileId; + } + 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)); + addSelectors(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; + if (operation === "POST /companies/:companyId/tools/connections" || operation === "POST /companies/:companyId/tools/applications") resource.provider = null; + else if (operation.includes("/tool-applications/:applicationId")) resource.provider = applicationProvider; + else resource.provider ??= text(body.provider) ?? text(body.galleryKey) ?? text(req.params.galleryKey); + if (operation === "PATCH /tool-applications/:applicationId" && Object.hasOwn(body, "metadata")) resource.requestedProvider = text(body.metadata?.sourceTemplateKey); + if (operation === "PATCH /tool-connections/:connectionId" && (body.config || body.transportConfig)) { + const proposed = body.config ?? body.transportConfig; + resource.requestedProvider = text(proposed.sourceTemplateKey) ?? text(proposed.provider) ?? text(proposed.ai?.provider); + } + for (const id of [req.params.agentId, req.query.agentId, req.query.agent, body.agentId, body.assignedAgentId, body.subjectAgentId, body.ownerAgentId, 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.access === "all_agents" || 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 : []) addSelector(member.binding ?? {}); + for (const binding of Array.isArray(body.bindings) ? body.bindings : []) addSelector(binding); + if (req.method !== "GET" && (operation.includes("/tools/policies") || operation.includes("/trust-rules/")) && + (policyAudiences.length ? policyAudiences : [{}]).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]; + const profileOperation = operation.includes("profile"); + if (profileOperation && (selected.defaultAction === "allow" || selected.selectorType === "tool_name" && text(selected.toolName) || selected.selectorType === "risk_level" && text(selected.riskLevel))) { + const connections = await lookup(`profile-catalog:${JSON.stringify([selected.defaultAction, selected.selectorType, selected.toolName, selected.riskLevel])}`, () => db.selectDistinct({ connectionId: toolCatalogEntries.connectionId }).from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, resource.companyId), eq(toolCatalogEntries.status, "active"), + selected.defaultAction === "allow" ? undefined : selected.selectorType === "tool_name" ? eq(toolCatalogEntries.toolName, String(selected.toolName)) : sql`${toolCatalogEntries.riskLevel} = ${String(selected.riskLevel)}`, + )).limit(1025)); + connections.forEach(connection => addSelector(connection)); + } + // 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", "routineId"] as const) { + for (const id of Array.isArray(selected[`${key}s`]) ? selected[`${key}s`] as unknown[] : []) addSelector({ [key]: uuid(id) }); + } + if (selected.applicationId) { + const [application] = await lookup(`application:${String(selected.applicationId)}`, () => db.select().from(toolApplications).where(eq(toolApplications.id, uuid(selected.applicationId)))); + bindCompany(application); + const connections = await lookup(`application-connections:${application.id}`, () => db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id)).limit(1025)); + connections.forEach(connection => addSelector({ 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 lookup(`catalog:${String(selected.catalogEntryId)}`, () => db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, uuid(selected.catalogEntryId)))); + bindCompany(entry); selectedConnectionId = entry.connectionId; + if (profileOperation && entry.applicationId) addSelector({ applicationId: entry.applicationId }); + } + if (selectedConnectionId && selectedConnectionId !== connectionId) { + const [connection] = await lookup(`connection:${selectedConnectionId}`, () => db.select({ id: toolConnections.id, companyId: toolConnections.companyId, createdByUserId: toolConnections.createdByUserId, + sourceTemplateKey: sql`${toolConnections.config}->>'sourceTemplateKey'`, provider: sql`${toolConnections.config}->>'provider'`, + }).from(toolConnections).where(eq(toolConnections.id, selectedConnectionId))); + bindCompany(connection); + extraResources.push({ ...resource, connectionId: connection.id, + provider: text(connection.sourceTemplateKey) ?? text(connection.provider), + selfServiceUserId: connection.createdByUserId, + }); + } + if (["project", "issue", "routine", "gateway"].includes(String(selected.targetType)) && text(selected.targetId)) bindingTargets.push({ targetType: String(selected.targetType), targetId: uuid(selected.targetId) }); + if (text(selected.routineId)) extraResources.push({ ...resource, routineId: uuid(selected.routineId) }); + 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 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 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 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", "access"].some(key => Object.hasOwn(body, key)) || + operation.includes("ai-connection-pools")); + if (changesAudience) await authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets] }); + const uniqueResources = new Map(extraResources.map(referenced => [JSON.stringify([ + referenced.connectionId, referenced.profileId, referenced.gatewayId, referenced.projectId, referenced.issueId, referenced.routineId, referenced.provider, referenced.purpose, + ]), referenced])); + if (uniqueResources.size > 1024) throw badRequest("Connection management request spans too many resources"); + const authorizeReferenced = async (afterHumanAction?: boolean) => { + for (const referenced of uniqueResources.values()) { + const resolved = { ...referenced, companyId: resource.companyId }; + const checkOptions = { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction }; + await authorize(req.actor, action, resolved, checkOptions); + if (changesGatewayBindings || changesAudience) await authorize(req.actor, "connections:profiles", resolved, checkOptions); + if (action === "connections:profiles" && resolved.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resolved.selfServiceAgentInstalls)) { + await authorize(req.actor, "connections:configure", resolved, checkOptions); + } + } + }; + for (const binding of bindingTargets) await management.authorizeBindingTarget(req.actor, action, resource, binding, { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction }); + await authorizeReferenced(); + context.reauthorize = async (continuation) => { + const current = await authorizeRequest(continuation?.afterHumanAction); + context.workContext = current.workContext; + context.resource = current.resource; + context.decision = current.decision; + context.audit = current.audit; + contexts.set(req, current); + }; + return context; + }; + try { + contexts.set(req, await authorizeRequest()); + next(); + } catch (error) { next(error); } + }; +} + +export async function aiLoginUserAction(db: Db, req: Request, companyId: string, sessionId: string) { + if (req.actor.type !== "agent") return {}; + const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)); + if (!company) throw notFound("Company not found"); + return requiredConnectionUserAction(req, { type: "ai_login", id: sessionId, companyId }, + `/${encodeURIComponent(company.prefix)}/apps/connect?aiLogin=${encodeURIComponent(sessionId)}`, + "Complete the provider sign-in for this saved AI account."); +} + +/** Collection projections use the same resolved-resource policy as individual reads. */ +export async function filterConnectionManagementRows(db: Db, req: Request, action: ConnectionManagementAction, + rows: T[], resource: (row: T) => Partial): Promise { + if (req.actor.type !== "agent") return rows; + const context = connectionRequestContext(req); + if (!context) throw forbidden("Connection management authority is required"); + const visible: T[] = []; + for (const row of rows) { + const decision = await connectionManagementService(db).capability(req.actor, action, { ...context.resource, ...resource(row) }, { mutation: false, readContext: context }); + if (decision.allowed) visible.push(row); + } + return visible; +} + +/** Policies may name several private resources; omit the entire rule if any is hidden. */ +export async function filterConnectionManagementPolicies }>(db: Db, req: Request, rows: T[]): Promise { + if (req.actor.type !== "agent") return rows; + const context = connectionRequestContext(req); + if (!context) throw forbidden("Connection management authority is required"); + const visible: T[] = []; + for (const row of rows) { + const resources: Partial[] = [{}]; + const ids = (key: string) => [row.selectors[key], ...(Array.isArray(row.selectors[`${key}s`]) ? row.selectors[`${key}s`] as unknown[] : [])].filter((id): id is string => typeof id === "string"); + for (const key of ["connectionId", "profileId", "gatewayId", "projectId", "issueId", "routineId"] 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, readContext: context })).allowed) { allowed = false; break; } + } + if (allowed) visible.push(row); + } + return visible; +} diff --git a/server/src/routes/email.ts b/server/src/routes/email.ts index 8a67356eb9e..a22dc123aa7 100644 --- a/server/src/routes/email.ts +++ b/server/src/routes/email.ts @@ -1,3 +1,4 @@ +import { connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js"; import { Router, type ErrorRequestHandler, type Request } from "express"; import { z } from "zod"; import { @@ -22,7 +23,8 @@ import type { function actor(req: Request): EmailActor { return req.actor.type === "agent" - ? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined } + ? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined, + ...(connectionRequestContext(req) ? { userId: connectionRequestUserId(req) ?? undefined, managementContext: connectionRequestContext(req) } : {}) } : { userId: req.actor.userId ?? "board", localImplicit: req.actor.source === "local_implicit", @@ -86,7 +88,9 @@ const agentmailErrorHandler: ErrorRequestHandler = (error, _req, res, next) => { export function emailRoutes(db: Db, service: EmailChannelService) { const router = Router(); + const managementGuard = connectionManagementGuard(db, "chat"); async function manager(req: Request, companyId: string) { + if (req.actor.type === "agent" && connectionRequestContext(req)) return; assertBoard(req); if (!hasCompanyAccess(req, companyId)) throw notFound("Email inbox not found"); @@ -104,7 +108,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { ) throw forbidden("Missing permission: tools:manage_connections"); } - router.get("/companies/:companyId/email/connections", async (req, res) => { + router.get("/companies/:companyId/email/connections", managementGuard("connections:read", "GET /companies/:companyId/email/connections", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; await manager(req, companyId); res.set("Cache-Control", "no-store").json( @@ -112,7 +116,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { ); }); router.post( - "/companies/:companyId/email/connections", + "/companies/:companyId/email/connections", managementGuard("connections:configure", "POST /companies/:companyId/email/connections", { personal: true }), validate(emailConnectionSchema), async (req, res) => { const companyId = req.params.companyId as string; @@ -129,7 +133,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { }, ); router.post( - "/companies/:companyId/email/connections/:connectionId/inspect", + "/companies/:companyId/email/connections/:connectionId/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/inspect", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; await manager(req, companyId); @@ -144,7 +148,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { }, ); router.post( - "/companies/:companyId/email/connections/:connectionId/check-address", + "/companies/:companyId/email/connections/:connectionId/check-address", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/check-address", { personal: true }), validate(emailAddressCheckSchema), async (req, res) => { const companyId = req.params.companyId as string; @@ -167,7 +171,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { ); }); router.post( - "/companies/:companyId/email/inspect", + "/companies/:companyId/email/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/inspect", { personal: true }), validate(z.object({ apiKey: z.string().min(1).max(4096) }).strict()), async (req, res) => { await manager(req, req.params.companyId as string); @@ -176,7 +180,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { }, ); router.post( - "/companies/:companyId/email/inboxes", + "/companies/:companyId/email/inboxes", managementGuard("connections:configure", "POST /companies/:companyId/email/inboxes", { personal: true }), validate(emailEndpointSetupSchema), async (req, res) => { await manager(req, req.params.companyId as string); @@ -192,7 +196,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { }, ); router.post( - "/email/inboxes/:endpointId/control", + "/email/inboxes/:endpointId/control", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/control", { personal: true }), validate( z.object({ action: z.enum(["pause", "resume", "remove"]) }).strict(), ), @@ -205,7 +209,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) { }, ); router.post( - "/email/inboxes/:endpointId/reconnect", + "/email/inboxes/:endpointId/reconnect", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/reconnect", { personal: true }), validate( z .object({ diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index c4001282b5c..892c6bf4406 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -835,6 +835,7 @@ const chatEndpointSetupResponseSchema = z webhookUrl: z.string().nullable().optional(), messagingEndpoint: z.string().nullable().optional(), webhookVerifiedAt: z.string().datetime().nullable().optional(), + credentialRotationId: z.string().uuid().nullable().optional(), webhookSecretConfigured: z.boolean().optional(), callbackSurfaces: z .object({ @@ -858,9 +859,24 @@ const chatEndpointSetupResponseSchema = z .strict(); const chatEndpointSetupSecretResponseSchema = z - .object({ webhookSecret: z.string().length(64) }) + .object({ webhookSecret: z.string().length(64), credentialRotationId: z.uuid() }) .strict(); +const chatEndpointCredentialHandoffSchema = z.object({ + status: z.literal("needs_user_action"), + interactionId: z.string().uuid().nullable(), + endpointId: z.string().uuid(), + connectionId: z.string().uuid(), + requiredUserAction: z.object({ + kind: z.literal("enter_credentials"), + userId: z.string(), + resource: z.object({ type: z.literal("chat_endpoint"), id: z.string().uuid(), companyId: z.string().uuid() }), + setupPath: z.string(), + reason: z.string(), + continuation: z.string(), + }), +}); + const chatEndpointResponseSchema = z .object({ id: z.string().uuid(), @@ -1271,6 +1287,8 @@ function paramsSchemaFromPath( } function registerCurrentRoute(input: { + "x-paperclip-transport"?: "rest" | "protocol"; + "x-paperclip-connection-management"?: string; method: string; path: string; tags: string[]; @@ -1291,6 +1309,7 @@ function registerCurrentRoute(input: { registry.registerPath({ method: input.method, path: input.path, + ...(input["x-paperclip-transport"] ? { "x-paperclip-transport": input["x-paperclip-transport"], "x-paperclip-connection-management": input["x-paperclip-connection-management"] } : {}), tags: input.tags, summary: input.summary, ...(request ? { request } : {}), @@ -1399,6 +1418,9 @@ const BOARD_ONLY_OPERATIONS = new Set([ "PUT /api/companies/{companyId}/prompt-injection-prevention", "GET /api/companies/{companyId}/prompt-injection-prevention/models", "POST /api/companies/{companyId}/prompt-injection-prevention/test", + "POST /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications", + "POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/replay", + "POST /api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay", "GET /api/companies/{companyId}/fast-response", "PUT /api/companies/{companyId}/fast-response", "GET /api/companies/{companyId}/fast-response/models", @@ -1412,21 +1434,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/companies/{companyId}/observability", "POST /api/companies/{companyId}/observability/test", ...browserUseOperations.map(([method, path]) => `${method.toUpperCase()} ${path}`), - "GET /api/companies/{companyId}/ai-connections", - "POST /api/companies/{companyId}/ai-connections", - "POST /api/companies/{companyId}/ai-connections/local", - "POST /api/companies/{companyId}/ai-connections/local/attempts", - "POST /api/companies/{companyId}/ai-connections/local/check", "POST /api/companies/{companyId}/ai-connections/local/attempts/{sessionId}/code", - "DELETE /api/companies/{companyId}/ai-connections/local/attempts/{sessionId}", - "PUT /api/companies/{companyId}/ai-connections/default", - "GET /api/companies/{companyId}/ai-connections/{connectionId}/active-runs", - "GET /api/companies/{companyId}/ai-connections/{connectionId}/usage", - "GET /api/companies/{companyId}/ai-connections/login/{sessionId}", - "GET /api/companies/{companyId}/ai-connection-pools", - "POST /api/companies/{companyId}/ai-connection-pools", - "DELETE /api/companies/{companyId}/ai-connection-pools/{poolId}", - "GET /api/companies/{companyId}/ai-connection-pools/{poolId}/inspection", "GET /api/companies/{companyId}/project-repositories", "PUT /api/projects/{id}/repositories", @@ -1505,54 +1513,8 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/issues/{id}/interactions/{interactionId}/respond", "POST /api/issues/{id}/interactions/{interactionId}/skip", "POST /api/issues/{id}/interactions/{interactionId}/withdraw", - "GET /api/companies/{companyId}/tools/gallery", - "GET /api/companies/{companyId}/tools/apps/{galleryKey}/preflight", - "POST /api/companies/{companyId}/tools/apps/connect", - "POST /api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access", - "POST /api/companies/{companyId}/tools/apps/{connectionId}/finish", - "GET /api/companies/{companyId}/tools/apps/attention", - "GET /api/companies/{companyId}/tools/action-requests", - "GET /api/companies/{companyId}/tools/examples", - "POST /api/companies/{companyId}/tools/examples/{id}/install", "POST /api/companies/{companyId}/tools/examples/{id}/smoke", - "GET /api/companies/{companyId}/tools/applications", - "POST /api/companies/{companyId}/tools/applications", - "PATCH /api/tool-applications/{applicationId}", - "DELETE /api/tool-applications/{applicationId}", - "GET /api/companies/{companyId}/tools/connections", - "POST /api/companies/{companyId}/tools/connections", - "POST /api/companies/{companyId}/tools/connections/{connectionId}/start-authorization", - "GET /api/tool-connections/{connectionId}", - "GET /api/tool-connections/{connectionId}/grants", - "POST /api/tool-connections/{connectionId}/grants/installations", - "POST /api/tool-connections/{connectionId}/grants/{grantId}/delegations", - "DELETE /api/tool-connections/{connectionId}/grants/{grantId}/delegations/{delegationId}", - "DELETE /api/tool-connections/{connectionId}/grants/{grantId}", - "GET /api/tool-connections/{connectionId}/usage", - "PATCH /api/tool-connections/{connectionId}", - "DELETE /api/tool-connections/{connectionId}", - "POST /api/tool-connections/{connectionId}/health-check", - "POST /api/tool-connections/{connectionId}/reconnect", - "POST /api/tool-connections/{connectionId}/railway/ssh", - "POST /api/tool-connections/{connectionId}/catalog/refresh", - "GET /api/tool-connections/{connectionId}/catalog", - "GET /api/tool-connections/{connectionId}/aggregator/apps", - "POST /api/tool-connections/{connectionId}/aggregator/apps/sync", - "POST /api/tool-connections/{connectionId}/aggregator/apps/refresh", - "PUT /api/tool-connections/{connectionId}/aggregator/discovery", - "GET /api/tool-connections/{connectionId}/composio/apps", - "POST /api/tool-connections/{connectionId}/composio/apps/sync", - "POST /api/tool-connections/{connectionId}/composio/apps/refresh", - "POST /api/tool-connections/{connectionId}/composio/apps/{toolkit}/setup", - "POST /api/tool-connections/{connectionId}/composio/apps/{toolkit}/accounts", - "GET /api/tool-connections/{connectionId}/activity", - "GET /api/tool-connections/{connectionId}/test-agents", - "GET /api/tool-connections/{connectionId}/test-agents/{agentId}/access", - "POST /api/tool-connections/{connectionId}/test-calls", - "GET /api/tool-connections/{connectionId}/test-calls/{actionRequestId}", - "POST /api/agents/me/connections/{connectionId}/start-authorization", "POST /api/agents/me/connections/{connectionId}/token", - "POST /api/tools/oauth/{connectionId}/start", "GET /api/tools/oauth/callback", "GET /api/tools/vercel-connect/callback", "GET /api/connection-intents/{interactionId}/setup-options", @@ -1560,53 +1522,13 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/connection-intents/{interactionId}/complete", "POST /api/agents/{id}/connection-intents/{interactionId}/adopt", "POST /api/connection-intents/{interactionId}/decline", - "GET /api/companies/{companyId}/tools/profiles", - "POST /api/companies/{companyId}/tools/profiles", - "GET /api/companies/{companyId}/tools/profiles/effective/agents/{agentId}", - "GET /api/tool-profiles/{profileId}/new-tools", - "PATCH /api/tool-profiles/{profileId}", - "POST /api/tool-profiles/{profileId}/duplicate", - "DELETE /api/tool-profiles/{profileId}", - "POST /api/tool-profiles/{profileId}/new-tools/review", - "POST /api/tool-profiles/{profileId}/entries", - "PATCH /api/tool-profile-entries/{entryId}", - "DELETE /api/tool-profile-entries/{entryId}", - "POST /api/companies/{companyId}/tools/profiles/{profileId}/bind", - "POST /api/companies/{companyId}/tools/profiles/{profileId}/unbind", - "GET /api/companies/{companyId}/tools/runtime-slots", - "POST /api/companies/{companyId}/tools/runtime-slots/{id}/stop", - "POST /api/companies/{companyId}/tools/runtime-slots/{id}/restart", - "GET /api/companies/{companyId}/tools/runtime-health", - "GET /api/companies/{companyId}/tools/runs/{runId}/decisions", - "GET /api/companies/{companyId}/tools/trust-rules", - "GET /api/companies/{companyId}/tools/policies", - "POST /api/companies/{companyId}/tools/policies/reorder", - "POST /api/companies/{companyId}/tools/policies", - "POST /api/companies/{companyId}/tools/policies/{policyId}/duplicate", - "PATCH /api/companies/{companyId}/tools/policies/{policyId}", - "DELETE /api/companies/{companyId}/tools/policies/{policyId}", "POST /api/companies/{companyId}/tools/action-requests/{actionRequestId}/trust-rule", - "POST /api/companies/{companyId}/tools/trust-rules/{policyId}/revoke", "GET /api/companies/{companyId}/tools/stdio-templates", "POST /api/companies/{companyId}/tools/stdio-templates", "POST /api/companies/{companyId}/tools/stdio-templates/{templateId}/disable", "POST /api/companies/{companyId}/tools/mcp/import-json", - "POST /api/companies/{companyId}/tools/policy/test", - "GET /api/companies/{companyId}/tools/gateways", - "POST /api/companies/{companyId}/tools/gateways", - "PATCH /api/tool-gateway/gateways/{gatewayId}", - "POST /api/tool-gateway/gateways/{gatewayId}/tokens", - "POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke", "POST /api/tool-gateway/action-requests/{id}/approve", "POST /api/tool-gateway/action-requests/{id}/decline", - "POST /api/companies/{companyId}/email/inspect", - "POST /api/companies/{companyId}/email/inboxes", - "GET /api/companies/{companyId}/email/connections", - "POST /api/companies/{companyId}/email/connections", - "POST /api/companies/{companyId}/email/connections/{connectionId}/inspect", - "POST /api/companies/{companyId}/email/connections/{connectionId}/check-address", - "POST /api/email/inboxes/{endpointId}/control", - "POST /api/email/inboxes/{endpointId}/reconnect", "POST /api/companies/{companyId}/email/deliveries/{publicationId}/resolve", // Chat endpoints expose provider credentials, identity mappings, access // policy, and replay controls. Every mounted handler asserts a board actor; @@ -1617,52 +1539,13 @@ const BOARD_ONLY_OPERATIONS = new Set([ "PUT /api/companies/{companyId}/slack/endpoints/{endpointId}/search", "POST /api/companies/{companyId}/slack/endpoints/{endpointId}/search/connect", "DELETE /api/companies/{companyId}/slack/endpoints/{endpointId}/search", - "GET /api/companies/{companyId}/chat-endpoints", - "POST /api/companies/{companyId}/chat-endpoints", - "GET /api/chat-endpoints/{endpointId}", - "GET /api/chat-endpoints/{endpointId}/github/configuration", - "PUT /api/chat-endpoints/{endpointId}/github/configuration", - "POST /api/chat-endpoints/{endpointId}/github/verify", - "PUT /api/chat-endpoints/{endpointId}/github/progress", - "GET /api/chat-endpoints/{endpointId}/github/reviews", - "GET /api/chat-endpoints/{endpointId}/github/reviews/{reviewId}", - "GET /api/chat-endpoints/{endpointId}/github/repositories", - "PUT /api/chat-endpoints/{endpointId}/github/repositories/access", - "GET /api/chat-endpoints/{endpointId}/github/personal-connections", "POST /api/chat-endpoints/{endpointId}/github/identity", - "POST /api/chat-endpoints/{endpointId}/github/people/lookup", - "POST /api/chat-endpoints/{endpointId}/github/registration", - "POST /api/chat-endpoints/{endpointId}/github/registration/restart", - "PUT /api/chat-endpoints/{endpointId}/github/draft", - "POST /api/chat-endpoints/{endpointId}/github/setup", - "POST /api/chat-endpoints/{endpointId}/github/identity/start", "POST /api/chat-endpoints/{endpointId}/github/identity/confirm", - "POST /api/chat-endpoints/{endpointId}/github/app", - "POST /api/chat-endpoints/{endpointId}/github/repositories/refresh", - "PATCH /api/chat-endpoints/{endpointId}", - "POST /api/chat-endpoints/{endpointId}/setup", - "POST /api/chat-endpoints/{endpointId}/setup-secret", - "POST /api/chat-endpoints/{endpointId}/test", - "POST /api/chat-endpoints/{endpointId}/finish", - "GET /api/chat-endpoints/{endpointId}/test-status", - "POST /api/chat-endpoints/{endpointId}/photon/inspect", - "GET /api/chat-endpoints/{endpointId}/resources", - "PUT /api/chat-endpoints/{endpointId}/resources", - "GET /api/chat-endpoints/{endpointId}/principals", - "POST /api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent", - "DELETE /api/chat-endpoints/{endpointId}/principals/{principalId}/link", "POST /api/chat-identity-links/confirm", "GET /api/chat-identity-links/preview", "POST /api/chat-identity-links/request-access", - "GET /api/chat-endpoints/{endpointId}/conversations", - "GET /api/chat-endpoints/{endpointId}/activity", - "POST /api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay", - "POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/replay", "POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve", "POST /api/chat-endpoints/{endpointId}/actions/{actionId}/resolve", - "POST /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications", - "GET /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status", - "GET /api/issues/{issueId}/chat-binding", ]); const INSTANCE_ADMIN_OPERATIONS = new Set([ @@ -1676,7 +1559,6 @@ const INSTANCE_ADMIN_OPERATIONS = new Set([ const CREATED_OPERATIONS = new Set([ "POST /api/adapters/install", - "POST /api/chat-endpoints/{endpointId}/setup-secret", "POST /api/companies/{companyId}/agent-hires", "POST /api/companies/{companyId}/agents", "POST /api/agents/{id}/keys", @@ -1725,11 +1607,7 @@ const CREATED_OPERATIONS = new Set([ "POST /api/admin/users/{userId}/promote-instance-admin", "POST /api/plugins/install", "POST /api/instance/database-backups", - "POST /api/companies/{companyId}/tools/applications", - "POST /api/companies/{companyId}/tools/connections", "POST /api/companies/{companyId}/tools/action-requests/{actionRequestId}/trust-rule", - "POST /api/companies/{companyId}/tools/gateways", - "POST /api/tool-gateway/gateways/{gatewayId}/tokens", "POST /api/tool-gateway/sessions", ]); @@ -1849,7 +1727,7 @@ function applyDocumentFixups(document: any): any { for (const [method, operation] of Object.entries( pathItem as Record, )) { - const authLevel = resolveOperationAuthLevel(method, path); + const authLevel = operation["x-paperclip-connection-management"] ? "authenticated" : resolveOperationAuthLevel(method, path); if (authLevel === "public") { operation.security = []; } else if (authLevel === "agent_run") { @@ -2328,6 +2206,7 @@ for (const [method,path,summary,body,response] of [ // Explicit task-bound email. Board setup and agent actions share the same vaulted // connection, while automatic chat publication never applies to these endpoints. registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/email/connections/{connectionId}/check-address", tags: ["Email"], @@ -2359,6 +2238,8 @@ for (const [method, path, summary, body, success] of [ ["post", "/api/companies/{companyId}/email/deliveries/{publicationId}/resolve", "Resolve uncertain email after checking the provider", z.object({ outcome: z.enum(["sent", "failed"]), providerMessageId: z.string().min(1).max(998).optional() }).strict(), 200], ] as const) { registry.registerPath({ method, path, tags: ["Email"], summary, + ...(!path.includes("/send") && !path.includes("/tasks/") && !path.includes("/deliveries/") && !(method === "get" && path.endsWith("/inboxes")) + ? { "x-paperclip-transport": "rest", "x-paperclip-connection-management": method === "get" ? "connections:read" : "connections:configure" } : {}), description: "AgentMail email connection. Available without the experimental chat setting. Internal comments never send email. Agent sends require assigned inbox and task ownership, active run authority, and configured action policies. Preserve the same idempotencyKey and payload across retries. New conversations create an email child task; replies require conversationId and replyToMessageId. Reply-all is deliberate and never includes Bcc.", request: { params: z.object(Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].map(match => [match[1], z.string().uuid()]))), ...(body ? { body: jsonBody(body) } : {}) }, responses: { [success]: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, @@ -2390,6 +2271,8 @@ registry.registerPath({ method: "get", path: "/api/slack/search/callback", tags: // ─── Chat Channels ───────────────────────────────────────────────────────── registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/chat-endpoints", tags: ["chat-channels"], @@ -2405,6 +2288,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/chat-endpoints", tags: ["chat-channels"], @@ -2426,6 +2311,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/chat-endpoints/{endpointId}", tags: ["chat-channels"], @@ -2536,9 +2423,9 @@ const githubBotOperations: Array<{ { method: "post", suffix: "identity/confirm", summary: "Confirm the observed GitHub identity", description: "Confirms only the signed-in configuring member's expiring observed identity and resumes the same setup draft.", body: z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict(), response: githubWizardResponseSchema }, { method: "post", suffix: "app", summary: "Connect an existing GitHub App", - description: "Validates App identity with GitHub and vaults write-only credentials server-side. Installation and signed webhook delivery must still be verified.", - body: 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"), - response: chatEndpointResponseSchema, + description: "Agents send an empty body to receive a saved credential-entry handoff. Only a human session may import App credentials and its webhook signing key. Human import validates App identity with GitHub and vaults credentials; installation and signed webhook delivery must still be verified.", + body: z.union([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"), z.object({}).strict()]), + response: z.union([chatEndpointResponseSchema, chatEndpointCredentialHandoffSchema]), }, { method: "post", suffix: "repositories/refresh", summary: "Refresh repositories available to the bot installation", @@ -2566,6 +2453,7 @@ for (const operation of githubBotOperations) { registry.registerPath({ method: operation.method, path: `/api/chat-endpoints/{endpointId}/github/${operation.suffix}`, + ...(!["identity", "identity/confirm"].includes(operation.suffix) ? { "x-paperclip-transport": "rest", "x-paperclip-connection-management": operation.method === "get" ? "connections:read" : "connections:configure" } : {}), tags: ["chat-channels"], summary: operation.summary, description: operation.description, request: { params: z.object({ endpointId: z.string().uuid(), ...(operation.suffix.includes("{reviewId}") ? { reviewId: z.string().uuid() } : {}) }), @@ -2582,6 +2470,8 @@ for (const operation of githubBotOperations) { } registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "patch", path: "/api/chat-endpoints/{endpointId}", tags: ["chat-channels"], @@ -2602,18 +2492,20 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/chat-endpoints/{endpointId}/setup", tags: ["chat-channels"], summary: "Configure or change chat endpoint lifecycle state", description: - "Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.", + "Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Agent-supplied signing secrets, webhook secrets, private keys, client secrets, and project secrets return a saved human credential-entry handoff without changing credentials. Authorized write-only bot tokens and API keys remain supported. Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.", request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(configureChatEndpointSchema), }, responses: { - 200: r.ok(chatEndpointResponseSchema), + 200: r.ok(z.union([chatEndpointResponseSchema, chatEndpointCredentialHandoffSchema])), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, @@ -2627,19 +2519,23 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"], summary: "Discover Slack setup methods and the current user's authorized workspaces", description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.", request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden }, }); for (const action of ["authorize", "provision"] as const) registry.registerPath({ - method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"], + method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", tags: ["chat-channels"], summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app", description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.", request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) }, responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"], summary: "Revoke the current user's workspace grant for future managed provisioning", description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.", @@ -2654,6 +2550,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:test", method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"], summary: "Connect the current user's Slack identity and send a test DM", description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.", @@ -2663,7 +2561,7 @@ registry.registerPath({ for (const action of ["registration", "install", "resume"] as const) { registry.registerPath({ - method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"], + method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", tags: ["chat-channels"], summary: { registration: "Create a Slack app for a saved draft", install: "Authorize installation of the saved Slack app", resume: "Connect already-vaulted Slack installation credentials" }[action], description: "Requires a board session, company membership, connection-management permission, and the chat connector rollout gate. Secrets are never returned. Creation request IDs are idempotent; uncertain creation requires an explicit checked-no-app confirmation before a new attempt. OAuth installation is bound to the initiating actor/session and expires in ten minutes.", request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "registration" ? slackRegistrationSchema : slackSetupActionSchema) }, @@ -2679,14 +2577,17 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/chat-endpoints/{endpointId}/setup-secret", tags: ["chat-channels"], summary: "Generate a GitHub webhook secret", description: - "Generates and vaults a new GitHub webhook secret. The plaintext value is returned exactly once for entry in GitHub; normal endpoint reads expose only whether one is configured. Regeneration immediately rotates the server-side value.", + "Authorized agents receive a saved human setup handoff without rotating or reading the signing secret. A human session generates and vaults a new secret, returned exactly once for entry in GitHub. Normal endpoint reads expose only whether one is configured. Human regeneration immediately rotates the server-side value.", request: { params: z.object({ endpointId: z.string().uuid() }) }, responses: { + 200: r.ok(chatEndpointCredentialHandoffSchema), 201: r.ok(chatEndpointSetupSecretResponseSchema), 401: r.unauthorized, 403: r.forbidden, @@ -2697,6 +2598,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/chat-endpoints/{endpointId}/photon/inspect", tags: ["chat-channels"], @@ -2732,6 +2635,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/chat-endpoints/{endpointId}/finish", tags: ["chat-channels"], summary: "Finish Slack or GitHub onboarding with an optional conversation test", description: "Requires verified provider delivery and an authorized identity linked to the current user. GitHub also checks the assigned agent’s current bot capabilities. Records that a full conversation test was not required.", @@ -2739,6 +2644,8 @@ registry.registerPath({ responses: { 200: r.ok(chatEndpointResponseSchema), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/chat-endpoints/{endpointId}/test-status", tags: ["chat-channels"], summary: "Check for the current user's first setup message", request: { params: z.object({ endpointId: z.string().uuid() }) }, @@ -2753,6 +2660,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/chat-endpoints/{endpointId}/test", tags: ["chat-channels"], @@ -2770,6 +2679,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/chat-endpoints/{endpointId}/resources", tags: ["chat-channels"], @@ -2786,6 +2697,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "put", path: "/api/chat-endpoints/{endpointId}/resources", tags: ["chat-channels"], @@ -2808,6 +2721,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/chat-endpoints/{endpointId}/principals", tags: ["chat-channels"], @@ -2824,6 +2739,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent", tags: ["chat-channels"], @@ -2849,6 +2766,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/chat-endpoints/{endpointId}/principals/{principalId}/link", tags: ["chat-channels"], @@ -2907,6 +2826,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/chat-endpoints/{endpointId}/conversations", tags: ["chat-channels"], @@ -2923,6 +2844,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:audit", method: "get", path: "/api/chat-endpoints/{endpointId}/activity", tags: ["chat-channels"], @@ -3059,6 +2982,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/issues/{issueId}/chat-binding", tags: ["chat-channels", "issues"], @@ -3076,6 +3000,8 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:test", method: "get", path: "/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status", tags: ["chat-channels"], @@ -4180,6 +4106,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/adapters/{type}/login-sessions", tags: ["adapters"], @@ -4198,6 +4125,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/adapters/{type}/login-sessions/active", tags: ["adapters"], @@ -4214,6 +4142,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}", tags: ["adapters"], @@ -4234,6 +4163,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}/cancel", tags: ["adapters"], @@ -7961,6 +7891,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/setup-token-login-sessions", tags: ["companies"], @@ -7980,6 +7911,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/setup-token-login-sessions/active", tags: ["companies"], @@ -7994,6 +7926,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}", tags: ["companies"], @@ -8010,6 +7943,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/prompt", tags: ["companies"], @@ -8045,6 +7979,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/completion", tags: ["companies"], @@ -8062,6 +7997,7 @@ registry.registerPath({ }); registry.registerPath({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/cancel", tags: ["companies"], @@ -11122,6 +11058,8 @@ registerCurrentRoute({ // --- AI runtime connections ------------------------------------------------- registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/ai-connection-pools", tags: ["ai-connections"], @@ -11130,6 +11068,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/ai-connection-pools", tags: ["ai-connections"], @@ -11139,6 +11079,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/companies/{companyId}/ai-connection-pools/{poolId}", tags: ["ai-connections"], @@ -11148,6 +11090,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection", tags: ["ai-connections"], @@ -11156,6 +11100,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/ai-connections/{connectionId}/usage", tags: ["ai-connections"], @@ -11165,6 +11111,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/ai-connections", tags: ["ai-connections"], @@ -11174,6 +11122,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/ai-connections", tags: ["ai-connections"], @@ -11183,6 +11133,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "put", path: "/api/companies/{companyId}/ai-connections/default", tags: ["ai-connections"], @@ -11192,6 +11144,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/ai-connections/{connectionId}/active-runs", tags: ["ai-connections"], @@ -11200,6 +11154,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/ai-connections/login/{sessionId}", tags: ["ai-connections"], @@ -11207,6 +11163,13 @@ registerCurrentRoute({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable }, }); +registerCurrentRoute({ + method: "get", path: "/api/companies/{companyId}/ai-connections/login/{sessionId}/setup", + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", + tags: ["ai-connections"], summary: "Reopen an owned saved AI sign-in with its original account and environment", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + // --- Tool access ------------------------------------------------------------- for (const [method, suffix, summary] of [ @@ -11296,6 +11259,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/gallery", tags: ["tool-access"], @@ -11303,6 +11268,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/apps/{galleryKey}/preflight", tags: ["tool-access"], @@ -11311,6 +11278,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/apps/connect", tags: ["tool-access"], @@ -11327,6 +11296,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/apps/{connectionId}/finish", tags: ["tool-access"], @@ -11343,6 +11314,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/apps/{connectionId}/finalize-oauth-access", tags: ["tool-access"], @@ -11359,6 +11332,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/apps/attention", tags: ["tool-access"], @@ -11366,6 +11341,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/action-requests", tags: ["tool-access"], @@ -11373,6 +11350,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/examples", tags: ["tool-access"], @@ -11380,6 +11359,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/examples/{id}/install", tags: ["tool-access"], @@ -11409,6 +11390,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/applications", tags: ["tool-access"], @@ -11416,6 +11399,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/applications", tags: ["tool-access"], @@ -11430,6 +11415,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "patch", path: "/api/tool-applications/{applicationId}", tags: ["tool-access"], @@ -11438,6 +11425,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/tool-applications/{applicationId}", tags: ["tool-access"], @@ -11445,6 +11434,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/connections", tags: ["tool-access"], @@ -11452,6 +11443,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/connections", tags: ["tool-access"], @@ -11466,6 +11459,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/tool-connections/{connectionId}", tags: ["tool-access"], @@ -11473,6 +11468,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/connections/{connectionId}/start-authorization", tags: ["tool-access"], @@ -11481,6 +11478,7 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/agents/me/connections/{connectionId}/start-authorization", tags: ["tool-access"], @@ -11489,6 +11487,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/tool-connections/{connectionId}/grants", tags: ["tool-access"], @@ -11496,6 +11496,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/tool-connections/{connectionId}/grants/installations", tags: ["tool-access"], @@ -11510,6 +11512,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/tool-connections/{connectionId}/grants/{grantId}/delegations", tags: ["tool-access"], @@ -11525,6 +11529,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "delete", path: "/api/tool-connections/{connectionId}/grants/{grantId}/delegations/{delegationId}", tags: ["tool-access"], @@ -11532,6 +11538,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/tool-connections/{connectionId}/grants/{grantId}", tags: ["tool-access"], @@ -11539,6 +11547,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/tool-connections/{connectionId}/usage", tags: ["tool-access"], @@ -11546,6 +11556,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "put", path: "/api/tool-connections/{connectionId}/grants/{grantId}/members", tags: ["tool-access"], @@ -11553,6 +11565,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tool-connections/{connectionId}/railway/ssh", tags: ["tool-access"], @@ -11570,6 +11584,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "get", path: "/api/tool-connections/{connectionId}/installs", tags: ["tool-access"], @@ -11577,6 +11593,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "put", path: "/api/tool-connections/{connectionId}/installs", tags: ["tool-access"], @@ -11585,6 +11603,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "patch", path: "/api/tool-connections/{connectionId}", tags: ["tool-access"], @@ -11593,6 +11613,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/tool-connections/{connectionId}", tags: ["tool-access"], @@ -11600,6 +11622,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tool-connections/{connectionId}/health-check", tags: ["tool-access"], @@ -11607,6 +11631,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tool-connections/{connectionId}/reconnect", tags: ["tool-access"], @@ -11622,6 +11648,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tool-connections/{connectionId}/catalog/refresh", tags: ["tool-access"], @@ -11629,6 +11657,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/tool-connections/{connectionId}/catalog", tags: ["tool-access"], @@ -11636,6 +11666,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:audit", method: "get", path: "/api/tool-connections/{connectionId}/activity", tags: ["tool-access"], @@ -11644,37 +11676,48 @@ registerCurrentRoute({ for (const provider of ["aggregator", "composio"] as const) { registerCurrentRoute({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: `/api/tool-connections/{connectionId}/${provider}/apps`, tags: ["tool-access"], summary: "List upstream account observations for the current connection manager", }); registerCurrentRoute({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: `/api/tool-connections/{connectionId}/${provider}/apps/sync`, tags: ["tool-access"], summary: "Start upstream account discovery without changing tool access", body: provider === "aggregator" ? aggregatorAppsSyncSchema : composioAppsSyncSchema, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:configure", method: "post", path: `/api/tool-connections/{connectionId}/${provider}/apps/refresh`, tags: ["tool-access"], summary: "Refresh upstream account observations", body: provider === "aggregator" ? aggregatorAppsRefreshSchema : composioAppsRefreshSchema, }); } registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "put", path: "/api/tool-connections/{connectionId}/aggregator/discovery", tags: ["tool-access"], summary: "Save manager-owned optional Arcade account discovery credentials", body: arcadeDiscoverySetupSchema, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tool-connections/{connectionId}/composio/apps/{toolkit}/setup", tags: ["tool-access"], summary: "Start or verify Composio app authorization through a saved gateway", body: composioAppSetupSchema, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tool-connections/{connectionId}/composio/apps/{toolkit}/accounts", tags: ["tool-access"], summary: "Manage a Composio account through a saved gateway", body: composioAppAccountSchema, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/tool-connections/{connectionId}/test-agents", tags: ["tool-access"], @@ -11682,6 +11725,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/tool-connections/{connectionId}/test-agents/{agentId}/access", tags: ["tool-access"], @@ -11689,6 +11734,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:test", method: "post", path: "/api/tool-connections/{connectionId}/test-calls", tags: ["tool-access"], @@ -11706,6 +11753,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:test", method: "get", path: "/api/tool-connections/{connectionId}/test-calls/{actionRequestId}", tags: ["tool-access"], @@ -11721,6 +11770,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/tools/oauth/{connectionId}/start", tags: ["tool-access"], @@ -11795,6 +11846,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "get", path: "/api/companies/{companyId}/tools/profiles", tags: ["tool-access"], @@ -11802,6 +11855,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/profiles", tags: ["tool-access"], @@ -11816,6 +11871,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "get", path: "/api/companies/{companyId}/tools/profiles/effective/agents/{agentId}", tags: ["tool-access"], @@ -11823,6 +11880,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "get", path: "/api/tool-profiles/{profileId}/new-tools", tags: ["tool-access"], @@ -11830,6 +11889,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "patch", path: "/api/tool-profiles/{profileId}", tags: ["tool-access"], @@ -11838,6 +11899,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/tool-profiles/{profileId}/duplicate", tags: ["tool-access"], @@ -11852,6 +11915,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "delete", path: "/api/tool-profiles/{profileId}", tags: ["tool-access"], @@ -11866,6 +11931,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/tool-profiles/{profileId}/new-tools/review", tags: ["tool-access"], @@ -11880,6 +11947,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/tool-profiles/{profileId}/entries", tags: ["tool-access"], @@ -11894,6 +11963,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "patch", path: "/api/tool-profile-entries/{entryId}", tags: ["tool-access"], @@ -11902,6 +11973,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "delete", path: "/api/tool-profile-entries/{entryId}", tags: ["tool-access"], @@ -11909,6 +11982,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/profiles/{profileId}/bind", tags: ["tool-access"], @@ -11925,6 +12000,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/profiles/{profileId}/unbind", tags: ["tool-access"], @@ -11934,6 +12011,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "get", path: "/api/companies/{companyId}/tools/runtime-slots", tags: ["tool-access"], @@ -11941,6 +12020,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "post", path: "/api/companies/{companyId}/tools/runtime-slots/{id}/stop", tags: ["tool-access"], @@ -11948,6 +12029,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "post", path: "/api/companies/{companyId}/tools/runtime-slots/{id}/restart", tags: ["tool-access"], @@ -11955,6 +12038,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "get", path: "/api/companies/{companyId}/tools/runtime-health", tags: ["tool-access"], @@ -11962,6 +12047,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:audit", method: "get", path: "/api/companies/{companyId}/tools/runs/{runId}/decisions", tags: ["tool-access"], @@ -11969,6 +12056,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "get", path: "/api/companies/{companyId}/tools/trust-rules", tags: ["tool-access"], @@ -11976,6 +12065,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "get", path: "/api/companies/{companyId}/tools/policies", tags: ["tool-access"], @@ -11983,6 +12074,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/policies/reorder", tags: ["tool-access"], @@ -11991,6 +12084,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/policies", tags: ["tool-access"], @@ -12005,6 +12100,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/policies/{policyId}/duplicate", tags: ["tool-access"], @@ -12019,6 +12116,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "patch", path: "/api/companies/{companyId}/tools/policies/{policyId}", tags: ["tool-access"], @@ -12027,6 +12126,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "delete", path: "/api/companies/{companyId}/tools/policies/{policyId}", tags: ["tool-access"], @@ -12048,6 +12149,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:profiles", method: "post", path: "/api/companies/{companyId}/tools/trust-rules/{policyId}/revoke", tags: ["tool-access"], @@ -12056,6 +12159,7 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", "x-paperclip-connection-management": "connections:read", method: "get", path: "/api/companies/{companyId}/tools/stdio-templates", tags: ["tool-access"], @@ -12101,6 +12205,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/tools/policy/test", tags: ["tool-access"], @@ -12163,6 +12269,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:gateway", method: "get", path: "/api/companies/{companyId}/tools/gateways", tags: ["tool-gateway"], @@ -12170,6 +12278,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:gateway", method: "post", path: "/api/companies/{companyId}/tools/gateways", tags: ["tool-gateway"], @@ -12185,6 +12295,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:gateway", method: "patch", path: "/api/tool-gateway/gateways/{gatewayId}", tags: ["tool-gateway"], @@ -12201,6 +12313,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:gateway", method: "post", path: "/api/tool-gateway/gateways/{gatewayId}/tokens", tags: ["tool-gateway"], @@ -12217,6 +12331,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:gateway", method: "post", path: "/api/tool-gateway/gateway-tokens/{tokenId}/revoke", tags: ["tool-gateway"], @@ -12572,6 +12688,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "get", path: "/api/tool-gateway/runtime-slots", tags: ["tool-gateway"], @@ -12580,6 +12698,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "post", path: "/api/tool-gateway/runtime-slots/{slotId}/stop", tags: ["tool-gateway"], @@ -12589,6 +12709,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:runtime", method: "post", path: "/api/tool-gateway/runtime-slots/{slotId}/restart", tags: ["tool-gateway"], @@ -12598,6 +12720,8 @@ registerCurrentRoute({ }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:audit", method: "get", path: "/api/tool-gateway/audit", tags: ["tool-gateway"], @@ -12741,6 +12865,8 @@ export function openApiRoutes() { } registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/ai-connections/local", tags: ["ai-connections"], @@ -12749,6 +12875,8 @@ registerCurrentRoute({ responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/ai-connections/local/attempts", tags: ["ai-connections"], summary: "Prepare an isolated local subscription sign-in", @@ -12756,12 +12884,16 @@ registerCurrentRoute({ responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "delete", path: "/api/companies/{companyId}/ai-connections/local/attempts/{sessionId}", tags: ["ai-connections"], summary: "Cancel an owned local subscription sign-in", responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, }); registerCurrentRoute({ + "x-paperclip-transport": "rest", + "x-paperclip-connection-management": "connections:configure", method: "post", path: "/api/companies/{companyId}/ai-connections/local/check", tags: ["ai-connections"], summary: "Check an owned local subscription sign-in without saving a connection", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 9ffebb7b9ed..1218ff4809d 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -58,8 +58,10 @@ import { updateToolProfileEntrySchema, updateToolProfileWithEntriesSchema, } from "@paperclipai/shared"; +import { filterConnectionManagementRows, filterConnectionManagementPolicies, connectionManagementGuard, requiredConnectionUserAction, connectionRequestActor, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js"; +import { connectionManagementService } from "../services/connection-management.js"; import { validate } from "../middleware/validate.js"; -import { getActorInfo, assertBoard, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, hasCompanyAccess } from "./authz.js"; +import { getActorInfo, assertBoard, assertBoardOrAgent, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, hasCompanyAccess } from "./authz.js"; import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { accessService, logActivity, toolAccessPolicyService, toolAccessService, vercelConnectIntegrationStatus } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; @@ -514,22 +516,29 @@ function connectorEnrollmentPrincipal(req: Request): string { } const access = accessService(db); + const managementGuard = connectionManagementGuard(db, "tool"); async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) { - assertBoard(req); + if (req.actor.type === "agent") { + const context = connectionRequestContext(req); + if (!context || context.resource.companyId !== companyId) throw forbidden("This operation requires an authorized management context"); + await context.reauthorize(); + return; + } + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const userId = req.actor.userId; + const userId = connectionRequestUserId(req); if (userId && await access.hasPermission(companyId, "user", userId, permissionKey)) return; throw forbidden(`Missing permission: ${permissionKey}`); } function activeToolMembership(req: Request, companyId: string) { - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; - const membership = Array.isArray(req.actor.memberships) - ? req.actor.memberships.find((item) => item.companyId === companyId) + const membership = Array.isArray((req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)) + ? (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.find((item) => item.companyId === companyId) : null; if (!membership || membership.status !== "active") { throw forbidden("User does not have active company access"); @@ -541,13 +550,13 @@ function connectorEnrollmentPrincipal(req: Request): string { } async function isToolConnectionManager(req: Request, companyId: string) { - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; - return Boolean(req.actor.userId && await access.hasPermission( + return Boolean(connectionRequestUserId(req) && await access.hasPermission( companyId, "user", - req.actor.userId, + connectionRequestUserId(req)!, "tools:manage_connections", )); } @@ -558,7 +567,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ) { activeToolMembership(req, connection.companyId); if (await isToolConnectionManager(req, connection.companyId)) return; - if (req.actor.userId && connection.createdByUserId === req.actor.userId) return; + if (connectionRequestUserId(req) && connection.createdByUserId === connectionRequestUserId(req)) return; throw forbidden( "Only the connection creator or a connection manager can configure, reconnect, or delete this connection", ); @@ -570,7 +579,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ) { activeToolMembership(req, connection.companyId); if (await isToolConnectionManager(req, connection.companyId)) return; - if (req.actor.userId && connection.createdByUserId === req.actor.userId) return; + if (connectionRequestUserId(req) && connection.createdByUserId === connectionRequestUserId(req)) return; const [grant] = await db .select({ id: connectionGrants.id }) .from(connectionGrants) @@ -580,8 +589,8 @@ function connectorEnrollmentPrincipal(req: Request): string { eq(connectionGrants.status, "active"), or( eq(connectionGrants.kind, "organization"), - req.actor.userId - ? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId)) + connectionRequestUserId(req) + ? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, connectionRequestUserId(req)!)) : eq(connectionGrants.kind, "organization"), ), )) @@ -608,8 +617,8 @@ function connectorEnrollmentPrincipal(req: Request): string { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { return { unrestricted: true, role: null, isViewer: false, isActive: true }; } - const membership = Array.isArray(req.actor.memberships) - ? req.actor.memberships.find((item) => item.companyId === companyId) + const membership = Array.isArray((req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)) + ? (req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.find((item) => item.companyId === companyId) : null; const isActive = Boolean(membership && membership.status === "active"); const role = membership?.membershipRole ?? null; @@ -617,14 +626,19 @@ function connectorEnrollmentPrincipal(req: Request): string { } async function isToolConnectionManagerQuiet(req: Request, companyId: string) { + if (req.actor.type === "agent") return (await connectionManagementService(db).capability(req.actor, "connections:configure", { + ...connectionRequestContext(req)?.resource, type: "connection_management", companyId, + connectionId: undefined, selfServiceUserId: undefined, readOnly: false, + operation: connectionRequestContext(req)?.resource.operation ?? "configure", + }, { readContext: connectionRequestContext(req) })).allowed; const membership = toolMembershipRole(req, companyId); if (membership.unrestricted) return true; if (!membership.isActive || membership.isViewer) return false; if (membership.role === "owner" || membership.role === "admin") return true; - return Boolean(req.actor.userId && await access.hasPermission( + return Boolean(connectionRequestUserId(req) && await access.hasPermission( companyId, "user", - req.actor.userId, + connectionRequestUserId(req)!, "tools:manage_connections", )); } @@ -655,32 +669,40 @@ function connectorEnrollmentPrincipal(req: Request): string { const membership = toolMembershipRole(req, connection.companyId); const isManager = await isToolConnectionManagerQuiet(req, connection.companyId); const mutationCapable = membership.unrestricted || (membership.isActive && !membership.isViewer); - const isCreator = Boolean(req.actor.userId && connection.createdByUserId === req.actor.userId); - const canConfigure = isManager || (mutationCapable && isCreator); + const isCreator = Boolean(connectionRequestUserId(req) && connection.createdByUserId === connectionRequestUserId(req)); + const management = connectionManagementService(db); + const resource = { ...connectionRequestContext(req)?.resource, type: "connection_management" as const, + companyId: connection.companyId, connectionId: connection.id, operation: "configure", + readOnly: false, selfServiceUserId: connection.createdByUserId }; + const manage = await management.capability(req.actor, "connections:configure", resource, { readContext: connectionRequestContext(req) }); + const use = await management.capability(req.actor, "connections:test", { ...resource, operation: "use" }, { mutation: false, readContext: connectionRequestContext(req) }); + const toolAccess = req.actor.type === "agent" && req.actor.agentId && options.toolGateway + ? await options.toolGateway.summarizeConnectionAccessForAgent({ companyId: connection.companyId, connectionId: connection.id, + agentId: req.actor.agentId, heartbeatRunId: req.actor.runId, issueId: resource.issueId, projectId: resource.projectId }) : null; + const usage = toolAccess ? { ...use, allowed: use.allowed && toolAccess.allowedCount > 0, + reason: !use.allowed ? use.reason : toolAccess.allowedCount > 0 ? "Configured actions are allowed; each call reauthorizes." + : toolAccess.askFirstCount > 0 ? "Configured actions require approval." : "No configured actions are available.", + requiresApproval: toolAccess.askFirstCount > 0, allowedCount: toolAccess.allowedCount, askFirstCount: toolAccess.askFirstCount } : use; + const canConfigure = manage.allowed; const editableAgentIds: string[] = []; if (mutationCapable) { - const companyAgents = await db - .select({ id: agents.id }) - .from(agents) - .where(eq(agents.companyId, connection.companyId)); + const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, connection.companyId)); for (const agent of companyAgents) { - const decision = await access.decide({ - actor: req.actor, - action: "agent_config:update", - resource: { type: "agent", companyId: connection.companyId, agentId: agent.id }, - }); - if (decision.allowed) editableAgentIds.push(agent.id); + const capability = await management.capability(req.actor, "connections:profiles", { ...resource, targetAgentId: agent.id }, { readContext: connectionRequestContext(req) }); + if (capability.allowed) editableAgentIds.push(agent.id); } } return { canConfigure, + management: manage, + usage, canCreateOrganizationGrant: canConfigure, canSetCompanyInstall: canConfigure, // Personal consent belongs to the person: it needs a named board user, and // it is never available to a viewer or to an unauthenticated principal. - canConnectAsCurrentUser: Boolean(req.actor.userId) && mutationCapable, + canConnectAsCurrentUser: Boolean(connectionRequestUserId(req)) && mutationCapable, canManageAgentInstalls: mutationCapable && editableAgentIds.length > 0, - canViewOtherPersonalIdentities: isManager, + canViewOtherPersonalIdentities: req.actor.type === "board" && isManager, editableAgentIds, }; } @@ -705,10 +727,10 @@ function connectorEnrollmentPrincipal(req: Request): string { } async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) { - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const userId = req.actor.userId; + const userId = connectionRequestUserId(req); if (userId) { for (const permissionKey of permissionKeys) { if (await access.hasPermission(companyId, "user", userId, permissionKey)) return; @@ -757,6 +779,7 @@ function connectorEnrollmentPrincipal(req: Request): string { } async function assertToolsAdmin(req: Request, companyId: string) { + assertBoard(req); await assertBoardToolPermission(req, companyId, "tools:admin"); } @@ -764,7 +787,7 @@ function connectorEnrollmentPrincipal(req: Request): string { await assertBoardToolPermission(req, companyId, "tools:manage_runtime"); } - router.post("/agents/me/connections/:connectionId/start-authorization", validate(startConnectionAuthorizationSchema), async (req, res) => { + router.post("/agents/me/connections/:connectionId/start-authorization", managementGuard("connections:configure", "POST /agents/me/connections/:connectionId/start-authorization", { personal: true }), validate(startConnectionAuthorizationSchema), async (req, res) => { if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) { res.status(401).json({ error: "Active agent run authentication required" }); return; @@ -778,8 +801,9 @@ function connectorEnrollmentPrincipal(req: Request): string { scopes: req.body.scopes, returnTo: req.body.returnTo, redirectUri: oauthRedirectUri(req), + managementContext: connectionRequestContext(req), }); - res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) }); + res.json({ ...requiredConnectionUserAction(req, { type: "connection", id: req.params.connectionId as string, companyId: req.actor.companyId }, result.authorizationUrl, "Authenticate the selected account and consent to the requested provider access."), url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) }); }); router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => { @@ -807,11 +831,12 @@ function connectorEnrollmentPrincipal(req: Request): string { }); function assertToolAppMutationAccess(req: Request, companyId: string) { + if (req.actor.type === "agent" && !connectionRequestContext(req)) throw forbidden("An authorized connection management operation is required"); activeToolMembership(req, companyId); } - router.get("/companies/:companyId/tools/gallery", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/gallery", managementGuard("connections:read", "GET /companies/:companyId/tools/gallery", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const advertisedProfiles = options.paperclipCloudConnector === undefined @@ -858,8 +883,8 @@ function connectorEnrollmentPrincipal(req: Request): string { }); }); - router.get("/companies/:companyId/tools/apps/:galleryKey/preflight", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/apps/:galleryKey/preflight", managementGuard("connections:read", "GET /companies/:companyId/tools/apps/:galleryKey/preflight", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const methodKey = typeof req.query.methodKey === "string" ? req.query.methodKey.trim() || null : null; @@ -882,7 +907,7 @@ function connectorEnrollmentPrincipal(req: Request): string { res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri })); }); - router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => { + router.post("/companies/:companyId/tools/apps/connect", managementGuard("connections:configure", "POST /companies/:companyId/tools/apps/connect", { personal: true }), validate(connectToolAppSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); // An omitted grant kind is the backward-compatible organization default. @@ -913,7 +938,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ); } try { - const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req)); + const result = await svc.connectGalleryApp(companyId, req.body, connectionRequestActor(req)); if (result.auth?.kind === "oauth") { try { // "Just me" must consent as the caller, not as the workspace: passing @@ -921,12 +946,12 @@ function connectorEnrollmentPrincipal(req: Request): string { // caller's personal grant instead of an organization grant // (PAP-17835). The service refuses any subject other than the actor, // so this cannot start consent on someone else's behalf. - const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null; + const personalSubjectUserId = (req.actor.type === "agent" || req.body.grantKind === "user") ? connectionRequestUserId(req) ?? null : null; const dedicatedSubjectAgentId = req.body.grantKind === "agent" ? req.body.subjectAgentId ?? null : null; const start = await svc.startOAuth(companyId, result.connectionId, { workspaceServices: req.body.workspaceServices, redirectUri: oauthRedirectUri(req), - actor: getActorInfo(req), + actor: connectionRequestActor(req), ...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}), ...(dedicatedSubjectAgentId ? { subjectAgentId: dedicatedSubjectAgentId } : {}), ...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}), @@ -950,8 +975,7 @@ function connectorEnrollmentPrincipal(req: Request): string { } await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_app.connected", entityType: "tool_connection", entityId: result.connectionId, @@ -965,38 +989,40 @@ function connectorEnrollmentPrincipal(req: Request): string { canMakeChangesActionCount: result.actions.canMakeChanges.length, }, }); - res.status(201).json(result); + res.status(201).json({ ...result, ...(result.auth?.kind === "oauth" && result.auth.startUrl + ? requiredConnectionUserAction(req, { type: "connection", id: result.connectionId, companyId }, result.auth.startUrl, + "Open this saved setup and authenticate the account in your browser.") : {}) }); } catch (error) { svc.ensureNoDuplicateNameError(error); } }); router.post( - "/companies/:companyId/tools/connections/:connectionId/start-authorization", + "/companies/:companyId/tools/connections/:connectionId/start-authorization", managementGuard("connections:configure", "POST /companies/:companyId/tools/connections/:connectionId/start-authorization", { personal: true }), validate(startConnectionAuthorizationSchema), async (req, res) => { const companyId = req.params.companyId as string; activeToolMembership(req, companyId); - if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) { + if (!connectionRequestUserId(req) || connectionRequestUserId(req) !== req.body.subjectUserId) { throw forbidden("Board users may only authorize their own connection subject"); } const existing = await svc.getConnection(req.params.connectionId as string, companyId); await assertToolConnectionAccess(req, existing); const result = await svc.startOAuth(companyId, existing.id, { redirectUri: oauthRedirectUri(req), - actor: getActorInfo(req), + actor: connectionRequestActor(req), subjectUserId: req.body.subjectUserId, scopes: req.body.scopes, returnTo: req.body.returnTo, }); - res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) }); + res.json({ ...requiredConnectionUserAction(req, { type: "connection", id: existing.id, companyId: existing.companyId }, result.authorizationUrl, "Authenticate the selected account and consent to the requested provider access."), url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) }); }, ); - router.post("/tools/oauth/:connectionId/start", validate(startToolOAuthSchema), async (req, res) => { + router.post("/tools/oauth/:connectionId/start", managementGuard("connections:configure", "POST /tools/oauth/:connectionId/start", { personal: true }), validate(startToolOAuthSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - const subjectUserId = req.body?.asCurrentUser === true ? req.actor.userId ?? null : null; + const subjectUserId = (req.actor.type === "agent" || req.body?.asCurrentUser === true) ? connectionRequestUserId(req) ?? null : null; const subjectAgentId = req.body?.asAgentId ?? null; if (req.body?.asCurrentUser === true && !subjectUserId) { throw forbidden("Connecting an app as yourself requires a signed-in user"); @@ -1013,13 +1039,13 @@ function connectorEnrollmentPrincipal(req: Request): string { const result = await svc.startOAuth(existing.companyId, existing.id, { workspaceServices: req.body.workspaceServices, redirectUri: oauthRedirectUri(req), - actor: getActorInfo(req), + actor: connectionRequestActor(req), returnTo: oauthBrowserOrigin(req) ?? undefined, ...(subjectUserId ? { subjectUserId } : {}), ...(subjectAgentId ? { subjectAgentId } : {}), ...(req.body?.interactionId ? { interactionId: req.body.interactionId } : {}), }); - res.json(result); + res.json({ ...result, ...requiredConnectionUserAction(req, { type: "connection", id: existing.id, companyId: existing.companyId }, result.authorizationUrl, "Authenticate the selected account and consent to the requested provider access.") }); }); router.get("/tools/oauth/cloud-connector/enrollment", async (req, res) => { @@ -1466,17 +1492,16 @@ function connectorEnrollmentPrincipal(req: Request): string { }); router.post( - "/companies/:companyId/tools/apps/:connectionId/finalize-oauth-access", + "/companies/:companyId/tools/apps/:connectionId/finalize-oauth-access", managementGuard("connections:configure", "POST /companies/:companyId/tools/apps/:connectionId/finalize-oauth-access", { personal: true }), validate(finalizeOAuthAccessSchema), async (req, res) => { const companyId = req.params.companyId as string; const existing = await svc.getConnection(req.params.connectionId as string, companyId); await assertToolConnectionConfigureAccess(req, existing); - const result = await svc.finalizeOAuthAccess(companyId, existing.id, req.body, getActorInfo(req)); + const result = await svc.finalizeOAuthAccess(companyId, existing.id, req.body, connectionRequestActor(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_app.oauth_access_finalized", entityType: "tool_connection", entityId: result.connection.id, @@ -1490,15 +1515,14 @@ function connectorEnrollmentPrincipal(req: Request): string { }, ); - router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => { + router.post("/companies/:companyId/tools/apps/:connectionId/finish", managementGuard("connections:configure", "POST /companies/:companyId/tools/apps/:connectionId/finish", { personal: true }), validate(finishToolAppSchema), async (req, res) => { const companyId = req.params.companyId as string; const existing = await svc.getConnection(req.params.connectionId as string, companyId); await assertToolConnectionConfigureAccess(req, existing); - const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, getActorInfo(req)); + const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, connectionRequestActor(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_app.finished", entityType: "tool_connection", entityId: result.connection.id, @@ -1514,39 +1538,46 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(result); }); - router.get("/companies/:companyId/tools/examples", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/examples", managementGuard("connections:read", "GET /companies/:companyId/tools/examples", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); res.json({ examples: await svc.listExamples(companyId) }); }); - router.get("/companies/:companyId/tools/apps/attention", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/apps/attention", managementGuard("connections:read", "GET /companies/:companyId/tools/apps/attention", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json(await svc.listAppsNeedingAttention(companyId)); + const result = await svc.listAppsNeedingAttention(companyId); + const apps = await filterConnectionManagementRows(db, req, "connections:read", result.apps, row => ({ connectionId: row.connection.id })); + res.json({ ...result, apps, totals: { + connections: apps.length, health: apps.filter(app => app.healthNeedsAttention).length, + quarantinedCatalogEntries: apps.reduce((sum, app) => sum + app.quarantinedCatalogEntryCount, 0), + pendingActionRequests: apps.reduce((sum, app) => sum + app.pendingActionRequestCount, 0), + newToolsPendingReview: apps.reduce((sum, app) => sum + app.newToolsPendingReviewCount, 0), + newToolsPendingProfiles: new Set(apps.flatMap(app => app.newToolsPendingProfiles.map(profile => profile.profileId))).size, + } }); }); - router.get("/companies/:companyId/tools/action-requests", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/action-requests", managementGuard("connections:read", "GET /companies/:companyId/tools/action-requests", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const statusRaw = typeof req.query.status === "string" ? req.query.status : "pending"; const status = (TOOL_ACTION_REQUEST_STATUSES as readonly string[]).includes(statusRaw) ? (statusRaw as (typeof TOOL_ACTION_REQUEST_STATUSES)[number]) : "pending"; - res.json({ actionRequests: await svc.listActionRequests(companyId, status) }); + res.json({ actionRequests: await filterConnectionManagementRows(db, req, "connections:read", await svc.listActionRequests(companyId, status), row => ({ connectionId: row.connectionId, issueId: row.request.issueId })) }); }); - router.post("/companies/:companyId/tools/examples/:id/install", async (req, res) => { + router.post("/companies/:companyId/tools/examples/:id/install", managementGuard("connections:configure", "POST /companies/:companyId/tools/examples/:id/install", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); - const result = await svc.installExample(companyId, req.params.id as string, getActorInfo(req)); + const result = await svc.installExample(companyId, req.params.id as string, connectionRequestActor(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_example.installed", entityType: "tool_example", entityId: result.example.id, @@ -1590,22 +1621,21 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(result); }); - router.get("/companies/:companyId/tools/applications", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/applications", managementGuard("connections:read", "GET /companies/:companyId/tools/applications", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); res.json({ applications: await svc.listApplications(companyId) }); }); - router.post("/companies/:companyId/tools/applications", validate(createToolApplicationSchema), async (req, res) => { + router.post("/companies/:companyId/tools/applications", managementGuard("connections:configure", "POST /companies/:companyId/tools/applications", { personal: true }), validate(createToolApplicationSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { - const application = await svc.createApplication(companyId, req.body); + const application = await svc.createApplication(companyId, req.body, connectionRequestContext(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_application.created", entityType: "tool_application", entityId: application.id, @@ -1617,16 +1647,15 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.patch("/tool-applications/:applicationId", validate(updateToolApplicationSchema), async (req, res) => { + router.patch("/tool-applications/:applicationId", managementGuard("connections:configure", "PATCH /tool-applications/:applicationId", { personal: true }), validate(updateToolApplicationSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); try { - const application = await svc.updateApplication(existing.id, req.body); + const application = await svc.updateApplication(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId: application.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_application.updated", entityType: "tool_application", entityId: application.id, @@ -1638,15 +1667,14 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.delete("/tool-applications/:applicationId", async (req, res) => { + router.delete("/tool-applications/:applicationId", managementGuard("connections:configure", "DELETE /tool-applications/:applicationId", { personal: true }), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); - const application = await svc.deleteApplication(existing.id); + const application = await svc.deleteApplication(existing.id, connectionRequestContext(req)); await logActivity(db, { companyId: application.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_application.deleted", entityType: "tool_application", entityId: application.id, @@ -1655,29 +1683,32 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(application); }); - router.get("/companies/:companyId/tools/connections", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/connections", managementGuard("connections:read", "GET /companies/:companyId/tools/connections", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const connections = await svc.listConnections(companyId, req.actor.userId); + const connections = await svc.listConnections(companyId, connectionRequestUserId(req) ?? undefined); const canManageConnections = await isToolConnectionManagerQuiet(req, companyId); - res.json({ - connections: filterVisibleToolConnections(connections, { - userId: req.actor.userId, - canManageConnections, - }), - }); + let visible = filterVisibleToolConnections(connections, { userId: connectionRequestUserId(req), canManageConnections }); + if (req.actor.type === "agent") { + const management = connectionManagementService(db); + const allowed = await Promise.all(visible.map(connection => management.capability(req.actor, "connections:read", { + type: "connection_management", companyId, connectionId: connection.id, + operation: "GET /companies/:companyId/tools/connections", readOnly: true, + }, { mutation: false, readContext: connectionRequestContext(req) }))); + visible = visible.filter((_, index) => allowed[index].allowed); + } + res.json({ connections: visible }); }); - router.post("/companies/:companyId/tools/connections", validate(createToolConnectionSchema), async (req, res) => { + router.post("/companies/:companyId/tools/connections", managementGuard("connections:configure", "POST /companies/:companyId/tools/connections", { personal: true }), validate(createToolConnectionSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { - const connection = await svc.createConnection(companyId, req.body, getActorInfo(req)); + const connection = await svc.createConnection(companyId, req.body, connectionRequestActor(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.created", entityType: "tool_connection", entityId: connection.id, @@ -1695,40 +1726,40 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.get("/tool-connections/:connectionId", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId", managementGuard("connections:read", "GET /tool-connections/:connectionId", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource( req, res, - svc.getConnection(req.params.connectionId as string, undefined, req.actor.userId), + svc.getConnection(req.params.connectionId as string, undefined, connectionRequestUserId(req) ?? undefined), "Tool connection not found", ); if (!connection) return; res.json(connection); }); - router.post("/tool-connections/:connectionId/railway/ssh", validate(configureRailwaySshSchema), async (req, res) => { - assertBoard(req); + router.post("/tool-connections/:connectionId/railway/ssh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/railway/ssh", { personal: true }), validate(configureRailwaySshSchema), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; await assertToolConnectionConfigureAccess(req, connection); - const setup = await svc.configureRailwaySsh(connection.id, connection.companyId, req.body, getActorInfo(req)).catch((error) => { + const setup = await svc.configureRailwaySsh(connection.id, connection.companyId, req.body, connectionRequestActor(req)).catch((error) => { if (error instanceof RailwayError) throw new HttpError(error.status, error.message); throw error; }); - await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "tool_connection.railway_ssh_updated", entityType: "tool_connection", entityId: connection.id, details: { action: req.body.action, grantId: req.body.grantId, enabled: setup?.enabled ?? false } }); + await logActivity(db, { companyId: connection.companyId, ...connectionRequestActor(req), action: "tool_connection.railway_ssh_updated", entityType: "tool_connection", entityId: connection.id, details: { action: req.body.action, grantId: req.body.grantId, enabled: setup?.enabled ?? false } }); res.json(setup); }); - router.get("/tool-connections/:connectionId/grants", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/grants", managementGuard("connections:read", "GET /tool-connections/:connectionId/grants", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; const listed = await svc.listConnectionGrants(connection.id, connection.companyId); const capabilities = await describeConnectionCapabilities(req, connection); const membership = toolMembershipRole(req, connection.companyId); const grantContext = { - userId: req.actor.userId ?? null, + userId: connectionRequestUserId(req) ?? null, isManager: await isToolConnectionManagerQuiet(req, connection.companyId), mutationCapable: membership.unrestricted || (membership.isActive && !membership.isViewer), }; @@ -1754,10 +1785,10 @@ function connectorEnrollmentPrincipal(req: Request): string { }); router.put( - "/tool-connections/:connectionId/grants/:grantId/members", + "/tool-connections/:connectionId/grants/:grantId/members", managementGuard("connections:profiles", "PUT /tool-connections/:connectionId/grants/:grantId/members", { personal: true }), validate(replaceConnectionGrantMembersSchema), async (req, res) => { - assertBoard(req); + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; // Viewer/inactive principals are rejected before anything else, so a @@ -1766,19 +1797,18 @@ function connectorEnrollmentPrincipal(req: Request): string { const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId); const target = grants.find((grant) => grant.id === req.params.grantId); if (!target) throw notFound("Connection grant not found"); - const isGrantCreator = Boolean(req.actor.userId && target.createdByUserId === req.actor.userId); + const isGrantCreator = Boolean(connectionRequestUserId(req) && target.createdByUserId === connectionRequestUserId(req)); if (!isGrantCreator) await assertToolConnectionConfigureAccess(req, connection); const memberUserIds = req.body.memberUserIds as string[]; const grant = await svc.replaceConnectionGrantMembers( connection.id, target.id, memberUserIds, - getActorInfo(req), + connectionRequestActor(req), ); await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.grant_audience_replaced", entityType: "connection_grant", entityId: grant.id, @@ -1788,8 +1818,8 @@ function connectorEnrollmentPrincipal(req: Request): string { }, ); - router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => { - assertBoard(req); + router.post("/tool-connections/:connectionId/grants/installations", managementGuard("connections:profiles", "POST /tool-connections/:connectionId/grants/installations", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; await assertToolConnectionConfigureAccess(req, connection); @@ -1802,11 +1832,10 @@ function connectorEnrollmentPrincipal(req: Request): string { providerTenant, credentialSecretRefs, isDefault: body.isDefault === true, - }, getActorInfo(req)); + }, connectionRequestActor(req)); await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.grant_added", entityType: "connection_grant", entityId: grant.id, @@ -1815,23 +1844,22 @@ function connectorEnrollmentPrincipal(req: Request): string { res.status(201).json(grant); }); - router.delete("/tool-connections/:connectionId/grants/:grantId", async (req, res) => { - assertBoard(req); + router.delete("/tool-connections/:connectionId/grants/:grantId", managementGuard("connections:configure", "DELETE /tool-connections/:connectionId/grants/:grantId", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId); const grantToRevoke = grants.find((grant) => grant.id === req.params.grantId); const canRevokeOwnGrant = Boolean( - req.actor.userId + connectionRequestUserId(req) && grantToRevoke - && (grantToRevoke.subjectUserId === req.actor.userId || grantToRevoke.createdByUserId === req.actor.userId), + && (grantToRevoke.subjectUserId === connectionRequestUserId(req) || grantToRevoke.createdByUserId === connectionRequestUserId(req)), ); if (!canRevokeOwnGrant) await assertToolConnectionConfigureAccess(req, connection); - const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req)); + const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, connectionRequestActor(req)); await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.grant_revoked", entityType: "connection_grant", entityId: grant.id, @@ -1840,11 +1868,11 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(grant); }); - router.post("/tool-connections/:connectionId/grants/:grantId/delegations", validate(createConnectionGrantDelegationSchema), async (req, res) => { - assertBoard(req); + router.post("/tool-connections/:connectionId/grants/:grantId/delegations", managementGuard("connections:profiles", "POST /tool-connections/:connectionId/grants/:grantId/delegations", { personal: true }), validate(createConnectionGrantDelegationSchema), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - const ownerUserId = req.actor.userId; + const ownerUserId = connectionRequestUserId(req); if (!ownerUserId) throw forbidden("A named user is required to delegate a personal grant"); const agentId = req.body.agentId; const delegation = await svc.createConnectionGrantDelegation( @@ -1852,11 +1880,11 @@ function connectorEnrollmentPrincipal(req: Request): string { req.params.grantId as string, agentId, ownerUserId, + connectionRequestContext(req), ); await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: ownerUserId, + ...connectionRequestActor(req), action: "tool_connection.grant_delegated", entityType: "connection_grant", entityId: req.params.grantId as string, @@ -1865,14 +1893,14 @@ function connectorEnrollmentPrincipal(req: Request): string { res.status(201).json(delegation); }); - router.delete("/tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", async (req, res) => { - assertBoard(req); + router.delete("/tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", managementGuard("connections:profiles", "DELETE /tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId); const grant = grants.find((candidate) => candidate.id === req.params.grantId); if (!grant) throw notFound("Connection grant not found"); - const canRevokeOwnDelegation = Boolean(req.actor.userId && grant.subjectUserId === req.actor.userId); + const canRevokeOwnDelegation = Boolean(connectionRequestUserId(req) && grant.subjectUserId === connectionRequestUserId(req)); if (!canRevokeOwnDelegation && !await isToolConnectionManager(req, connection.companyId)) { throw forbidden("Only the personal grant owner or a connection manager can revoke a delegation"); } @@ -1880,12 +1908,11 @@ function connectorEnrollmentPrincipal(req: Request): string { connection.id, grant.id, req.params.delegationId as string, - getActorInfo(req), + connectionRequestActor(req), ); await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.grant_delegation_revoked", entityType: "connection_grant", entityId: grant.id, @@ -1894,8 +1921,8 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(delegation); }); - router.get("/tool-connections/:connectionId/usage", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/usage", managementGuard("connections:read", "GET /tool-connections/:connectionId/usage", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; const range = req.query.range === "30d" ? "30d" : req.query.range === undefined || req.query.range === "7d" ? "7d" : null; @@ -1903,18 +1930,18 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(await svc.getConnectionUsage(connection.id, range, connection.companyId)); }); - router.get("/tool-connections/:connectionId/installs", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/installs", managementGuard("connections:profiles", "GET /tool-connections/:connectionId/installs", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; res.json({ connectionId: connection.id, installs: connection.installs ?? [] }); }); router.put( - "/tool-connections/:connectionId/installs", + "/tool-connections/:connectionId/installs", managementGuard("connections:profiles", "PUT /tool-connections/:connectionId/installs", { personal: true }), validate(putToolConnectionInstallsSchema), async (req, res) => { - assertBoard(req); + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; const existingInstalls = await db @@ -1958,11 +1985,10 @@ function connectorEnrollmentPrincipal(req: Request): string { } } } - const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req)); + const snapshot = await svc.putConnectionInstalls(connection.id, req.body, connectionRequestActor(req)); await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.installs_synced", entityType: "tool_connection", entityId: connection.id, @@ -1974,8 +2000,8 @@ function connectorEnrollmentPrincipal(req: Request): string { }, ); - router.get("/tool-connections/:connectionId/test-agents", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/test-agents", managementGuard("connections:read", "GET /tool-connections/:connectionId/test-agents", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); if (!options.toolGateway) { res.status(501).json({ error: "Tool gateway service is not configured" }); return; @@ -2015,8 +2041,8 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json({ agents: candidates }); }); - router.get("/tool-connections/:connectionId/test-agents/:agentId/access", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/test-agents/:agentId/access", managementGuard("connections:read", "GET /tool-connections/:connectionId/test-agents/:agentId/access", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); if (!options.toolGateway) { res.status(501).json({ error: "Tool gateway service is not configured" }); return; @@ -2035,69 +2061,69 @@ function connectorEnrollmentPrincipal(req: Request): string { }); async function composioAppManager(req: Request, res: Response) { - assertBoard(req); + assertBoardOrAgent(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return null; if (!await isToolConnectionManager(req, connection.companyId)) throw forbidden("Only a connection manager can configure apps and grant agent access"); return connection; } - router.get("/tool-connections/:connectionId/aggregator/apps", async (req, res) => { + router.get("/tool-connections/:connectionId/aggregator/apps", managementGuard("connections:read", "GET /tool-connections/:connectionId/aggregator/apps", { personal: true }), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; res.set("Cache-Control", "private, no-store"); - res.json(await svc.listAggregatorApps(connection.id, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.listAggregatorApps(connection.id, { ...connectionRequestActor(req), })); }); - router.post("/tool-connections/:connectionId/aggregator/apps/sync", validate(aggregatorAppsSyncSchema), async (req, res) => { + router.post("/tool-connections/:connectionId/aggregator/apps/sync", managementGuard("connections:configure", "POST /tool-connections/:connectionId/aggregator/apps/sync", { personal: true }), validate(aggregatorAppsSyncSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.syncAggregatorApps(connection.id, req.body.force, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.syncAggregatorApps(connection.id, req.body.force, { ...connectionRequestActor(req), })); }); - router.post("/tool-connections/:connectionId/aggregator/apps/refresh", validate(aggregatorAppsRefreshSchema), async (req, res) => { + router.post("/tool-connections/:connectionId/aggregator/apps/refresh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/aggregator/apps/refresh", { personal: true }), validate(aggregatorAppsRefreshSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.refreshAggregatorApps(connection.id, req.body.toolkits, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.refreshAggregatorApps(connection.id, req.body.toolkits, { ...connectionRequestActor(req), })); }); - router.put("/tool-connections/:connectionId/aggregator/discovery", validate(arcadeDiscoverySetupSchema), async (req, res) => { + router.put("/tool-connections/:connectionId/aggregator/discovery", managementGuard("connections:configure", "PUT /tool-connections/:connectionId/aggregator/discovery", { personal: true }), validate(arcadeDiscoverySetupSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.configureArcadeDiscovery(connection.id, req.body, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.configureArcadeDiscovery(connection.id, req.body, { ...connectionRequestActor(req), })); }); - router.get("/tool-connections/:connectionId/composio/apps", async (req, res) => { + router.get("/tool-connections/:connectionId/composio/apps", managementGuard("connections:read", "GET /tool-connections/:connectionId/composio/apps", { personal: true }), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.listComposioApps(connection.id, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.listComposioApps(connection.id, { ...connectionRequestActor(req), })); }); - router.post("/tool-connections/:connectionId/composio/apps/sync", validate(composioAppsSyncSchema), async (req, res) => { + router.post("/tool-connections/:connectionId/composio/apps/sync", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/sync", { personal: true }), validate(composioAppsSyncSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.syncComposioApps(connection.id, req.body.force, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.syncComposioApps(connection.id, req.body.force, { ...connectionRequestActor(req), })); }); - router.post("/tool-connections/:connectionId/composio/apps/refresh", validate(composioAppsRefreshSchema), async (req, res) => { + router.post("/tool-connections/:connectionId/composio/apps/refresh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/refresh", { personal: true }), validate(composioAppsRefreshSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.refreshComposioApps(connection.id, req.body.toolkits, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.refreshComposioApps(connection.id, req.body.toolkits, { ...connectionRequestActor(req), })); }); - router.post("/tool-connections/:connectionId/composio/apps/:toolkit/accounts", validate(composioAppAccountSchema), async (req, res) => { + router.post("/tool-connections/:connectionId/composio/apps/:toolkit/accounts", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/:toolkit/accounts", { personal: true }), validate(composioAppAccountSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; - res.json(await svc.manageComposioAppAccount(connection.id, req.params.toolkit as string, req.body, { actorType: "user", actorId: req.actor.userId ?? "board" })); + res.json(await svc.manageComposioAppAccount(connection.id, req.params.toolkit as string, req.body, { ...connectionRequestActor(req), })); }); - router.post("/tool-connections/:connectionId/composio/apps/:toolkit/setup", validate(composioAppSetupSchema), async (req, res) => { + router.post("/tool-connections/:connectionId/composio/apps/:toolkit/setup", managementGuard("connections:configure", "POST /tool-connections/:connectionId/composio/apps/:toolkit/setup", { personal: true }), validate(composioAppSetupSchema), async (req, res) => { const connection = await composioAppManager(req, res); if (!connection) return; const result = await svc.setupComposioApp(connection.id, req.params.toolkit as string, req.body, - { actorType: "user", actorId: req.actor.userId ?? "board" }); + { ...connectionRequestActor(req), }); res.json(result); }); - router.post("/tool-connections/:connectionId/test-calls", validate(toolConnectionTestCallSchema), async (req, res) => { - assertBoard(req); + router.post("/tool-connections/:connectionId/test-calls", managementGuard("connections:test", "POST /tool-connections/:connectionId/test-calls", { personal: true }), validate(toolConnectionTestCallSchema), async (req, res) => { + assertBoardOrAgent(req); if (!options.toolGateway) { res.status(501).json({ error: "Tool gateway service is not configured" }); return; @@ -2108,10 +2134,11 @@ function connectorEnrollmentPrincipal(req: Request): string { await assertCanTestAsAgent(req, connection.companyId, req.body.agentId); try { const result = await options.toolGateway.executeTestCall({ + managementContext: connectionRequestContext(req), companyId: connection.companyId, connectionId: connection.id, agentId: req.body.agentId, - userId: req.actor.userId ?? "board", + userId: connectionRequestUserId(req) ?? "board", toolName: req.body.toolName, parameters: req.body.parameters ?? {}, }); @@ -2121,8 +2148,8 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.get("/tool-connections/:connectionId/test-calls/:actionRequestId", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/test-calls/:actionRequestId", managementGuard("connections:test", "GET /tool-connections/:connectionId/test-calls/:actionRequestId", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); if (!options.toolGateway) { res.status(501).json({ error: "Tool gateway service is not configured" }); return; @@ -2132,6 +2159,7 @@ function connectorEnrollmentPrincipal(req: Request): string { await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); try { const status = await options.toolGateway.getTestCallStatus({ + actor: req.actor, companyId: connection.companyId, connectionId: connection.id, actionRequestId: req.params.actionRequestId as string, @@ -2142,20 +2170,19 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => { + router.patch("/tool-connections/:connectionId", managementGuard("connections:configure", "PATCH /tool-connections/:connectionId", { personal: true }), validate(updateToolConnectionSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; await assertToolConnectionConfigureAccess(req, existing); if (aiConnectionRouterPluginKey(existing)) throw badRequest("Update this connection pool through its pool settings."); - const connection = await svc.updateConnection(existing.id, req.body); + const connection = await svc.updateConnection(existing.id, req.body, connectionRequestContext(req)); const lifecycleChanges = classifyConnectionUpdate( { enabled: existing.enabled, config: existing.config }, { enabled: connection.enabled, config: connection.config }, ); const baseLog = { companyId: connection.companyId, - actorType: "user" as const, - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.updated", entityType: "tool_connection", entityId: connection.id, @@ -2189,7 +2216,7 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(connection); }); - router.delete("/tool-connections/:connectionId", async (req, res) => { + router.delete("/tool-connections/:connectionId", managementGuard("connections:configure", "DELETE /tool-connections/:connectionId", { personal: true }), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; await assertToolConnectionConfigureAccess(req, existing); @@ -2198,7 +2225,7 @@ function connectorEnrollmentPrincipal(req: Request): string { const { connection, removal } = await svc.archiveConnection( existing.id, existing.companyId, - getActorInfo(req), + connectionRequestActor(req), ); const applicationAfter = await svc.getApplication(existing.applicationId); // The receipt is counts and outcomes only. Removal is a revocation boundary @@ -2206,8 +2233,7 @@ function connectorEnrollmentPrincipal(req: Request): string { // company-readable activity, so it never carries a secret name or value. await logActivity(db, { companyId: connection.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.archived", entityType: "tool_connection", entityId: connection.id, @@ -2216,8 +2242,7 @@ function connectorEnrollmentPrincipal(req: Request): string { if (applicationBefore.status !== "archived" && applicationAfter.status === "archived") { await logActivity(db, { companyId: applicationAfter.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_application.archived", entityType: "tool_application", entityId: applicationAfter.id, @@ -2227,16 +2252,16 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json({ ...connection, removal }); }); - router.post("/tool-connections/:connectionId/health-check", async (req, res) => { + router.post("/tool-connections/:connectionId/health-check", managementGuard("connections:configure", "POST /tool-connections/:connectionId/health-check", { personal: true }), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; if (existing.credentialPolicy === "per_user") await assertToolConnectionAccess(req, existing); else await assertToolConnectionConfigureAccess(req, existing); - res.json(await svc.checkHealth(existing.id, getActorInfo(req))); + res.json(await svc.checkHealth(existing.id, connectionRequestActor(req))); }); router.post( - "/tool-connections/:connectionId/reconnect", + "/tool-connections/:connectionId/reconnect", managementGuard("connections:configure", "POST /tool-connections/:connectionId/reconnect", { personal: true }), validate(reconnectToolAppSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); @@ -2246,12 +2271,11 @@ function connectorEnrollmentPrincipal(req: Request): string { existing.id, existing.companyId, req.body, - getActorInfo(req), + connectionRequestActor(req), ); await logActivity(db, { companyId: existing.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_app.reconnected", entityType: "tool_connection", entityId: existing.id, @@ -2261,15 +2285,14 @@ function connectorEnrollmentPrincipal(req: Request): string { }, ); - router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => { + router.post("/tool-connections/:connectionId/catalog/refresh", managementGuard("connections:configure", "POST /tool-connections/:connectionId/catalog/refresh", { personal: true }), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; await assertToolConnectionConfigureAccess(req, existing); - const result = await svc.refreshCatalog(existing.id, getActorInfo(req)); + const result = await svc.refreshCatalog(existing.id, connectionRequestActor(req)); await logActivity(db, { companyId: existing.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_connection.catalog_refresh", entityType: "tool_connection", entityId: existing.id, @@ -2281,45 +2304,44 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(result); }); - router.get("/tool-connections/:connectionId/catalog", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/catalog", managementGuard("connections:read", "GET /tool-connections/:connectionId/catalog", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; res.json({ catalog: await svc.listCatalog(existing.id, existing.companyId, getActorInfo(req)) }); }); - router.get("/tool-connections/:connectionId/activity", async (req, res) => { - assertBoard(req); + router.get("/tool-connections/:connectionId/activity", managementGuard("connections:audit", "GET /tool-connections/:connectionId/activity", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; const limitRaw = Number(req.query.limit ?? 20); const limit = Number.isFinite(limitRaw) ? limitRaw : 20; - res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit)); + res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit, connectionRequestContext(req))); }); - router.get("/companies/:companyId/tools/profiles", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/profiles", managementGuard("connections:profiles", "GET /companies/:companyId/tools/profiles", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json({ profiles: await svc.listProfiles(companyId) }); + res.json({ profiles: await filterConnectionManagementRows(db, req, "connections:profiles", await svc.listProfiles(companyId), row => ({ profileId: row.id })) }); }); - router.get("/tool-profiles/:profileId/new-tools", async (req, res) => { - assertBoard(req); + router.get("/tool-profiles/:profileId/new-tools", managementGuard("connections:profiles", "GET /tool-profiles/:profileId/new-tools", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; res.json(await svc.listProfileNewTools(existing.id, existing.companyId)); }); - router.post("/companies/:companyId/tools/profiles", validate(createToolProfileWithEntriesSchema), async (req, res) => { + router.post("/companies/:companyId/tools/profiles", managementGuard("connections:profiles", "POST /companies/:companyId/tools/profiles", { personal: true }), validate(createToolProfileWithEntriesSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { - const profile = await svc.createProfile(companyId, req.body); + const profile = await svc.createProfile(companyId, req.body, connectionRequestContext(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile.created", entityType: "tool_profile", entityId: profile.id, @@ -2331,23 +2353,27 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.get("/companies/:companyId/tools/profiles/effective/agents/:agentId", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/profiles/effective/agents/:agentId", managementGuard("connections:profiles", "GET /companies/:companyId/tools/profiles/effective/agents/:agentId", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json(await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string)); + const effective = await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string); + const profiles = await filterConnectionManagementRows(db, req, "connections:profiles", effective.profiles, profile => ({ profileId: profile.id })); + const connectionIds = [...new Set([...effective.allowedTools.map(tool => tool.connectionId), ...effective.installedConnections.map(connection => connection.id)])]; + const connections = await filterConnectionManagementRows(db, req, "connections:profiles", connectionIds, connectionId => ({ connectionId })); + if (profiles.length !== effective.profiles.length || connections.length !== connectionIds.length) throw forbidden("Effective profile resources are unavailable to this delegated principal"); + res.json(effective); }); - router.patch("/tool-profiles/:profileId", validate(updateToolProfileWithEntriesSchema), async (req, res) => { + router.patch("/tool-profiles/:profileId", managementGuard("connections:profiles", "PATCH /tool-profiles/:profileId", { personal: true }), validate(updateToolProfileWithEntriesSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); try { - const profile = await svc.updateProfile(existing.id, req.body); + const profile = await svc.updateProfile(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId: profile.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile.updated", entityType: "tool_profile", entityId: profile.id, @@ -2359,16 +2385,15 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.post("/tool-profiles/:profileId/duplicate", validate(duplicateToolProfileSchema), async (req, res) => { + router.post("/tool-profiles/:profileId/duplicate", managementGuard("connections:profiles", "POST /tool-profiles/:profileId/duplicate", { personal: true }), validate(duplicateToolProfileSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); try { - const profile = await svc.duplicateProfile(existing.id, req.body); + const profile = await svc.duplicateProfile(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId: profile.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile.duplicated", entityType: "tool_profile", entityId: profile.id, @@ -2385,15 +2410,14 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.delete("/tool-profiles/:profileId", validate(deleteToolProfileSchema), async (req, res) => { + router.delete("/tool-profiles/:profileId", managementGuard("connections:profiles", "DELETE /tool-profiles/:profileId", { personal: true }), validate(deleteToolProfileSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); - const result = await svc.deleteProfile(existing.id, req.body); + const result = await svc.deleteProfile(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId: existing.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile.deleted", entityType: "tool_profile", entityId: existing.id, @@ -2407,15 +2431,14 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(result); }); - router.post("/tool-profiles/:profileId/new-tools/review", validate(reviewToolProfileNewToolsSchema), async (req, res) => { + router.post("/tool-profiles/:profileId/new-tools/review", managementGuard("connections:profiles", "POST /tool-profiles/:profileId/new-tools/review", { personal: true }), validate(reviewToolProfileNewToolsSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); - const result = await svc.reviewProfileNewTools(existing.id, req.body, getActorInfo(req)); + const result = await svc.reviewProfileNewTools(existing.id, req.body, connectionRequestActor(req)); await logActivity(db, { companyId: existing.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile.new_tools_reviewed", entityType: "tool_profile", entityId: existing.id, @@ -2428,15 +2451,14 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(result); }); - router.post("/tool-profiles/:profileId/entries", validate(createToolProfileEntryForProfileSchema), async (req, res) => { + router.post("/tool-profiles/:profileId/entries", managementGuard("connections:profiles", "POST /tool-profiles/:profileId/entries", { personal: true }), validate(createToolProfileEntryForProfileSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); - const entry = await svc.addProfileEntry(existing.id, req.body); + const entry = await svc.addProfileEntry(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId: entry.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile_entry.created", entityType: "tool_profile_entry", entityId: entry.id, @@ -2445,15 +2467,14 @@ function connectorEnrollmentPrincipal(req: Request): string { res.status(201).json(entry); }); - router.patch("/tool-profile-entries/:entryId", validate(updateToolProfileEntrySchema), async (req, res) => { + router.patch("/tool-profile-entries/:entryId", managementGuard("connections:profiles", "PATCH /tool-profile-entries/:entryId", { personal: true }), validate(updateToolProfileEntrySchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); - const entry = await svc.updateProfileEntry(existing.id, req.body); + const entry = await svc.updateProfileEntry(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId: entry.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile_entry.updated", entityType: "tool_profile_entry", entityId: entry.id, @@ -2462,15 +2483,14 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(entry); }); - router.delete("/tool-profile-entries/:entryId", async (req, res) => { + router.delete("/tool-profile-entries/:entryId", managementGuard("connections:profiles", "DELETE /tool-profile-entries/:entryId", { personal: true }), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); - const entry = await svc.deleteProfileEntry(existing.id); + const entry = await svc.deleteProfileEntry(existing.id, connectionRequestContext(req)); await logActivity(db, { companyId: entry.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile_entry.deleted", entityType: "tool_profile_entry", entityId: entry.id, @@ -2480,18 +2500,17 @@ function connectorEnrollmentPrincipal(req: Request): string { }); router.post( - "/companies/:companyId/tools/profiles/:profileId/bind", + "/companies/:companyId/tools/profiles/:profileId/bind", managementGuard("connections:profiles", "POST /companies/:companyId/tools/profiles/:profileId/bind", { personal: true }), validate(createToolProfileBindingForProfileSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); const existing = await svc.getProfile(req.params.profileId as string, companyId); try { - const binding = await svc.bindProfile(existing.id, req.body, getActorInfo(req)); + const binding = await svc.bindProfile(existing.id, req.body, connectionRequestActor(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile_binding.created", entityType: "tool_profile_binding", entityId: binding.id, @@ -2505,17 +2524,16 @@ function connectorEnrollmentPrincipal(req: Request): string { ); router.post( - "/companies/:companyId/tools/profiles/:profileId/unbind", + "/companies/:companyId/tools/profiles/:profileId/unbind", managementGuard("connections:profiles", "POST /companies/:companyId/tools/profiles/:profileId/unbind", { personal: true }), validate(unbindToolProfileBindingSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); const existing = await svc.getProfile(req.params.profileId as string, companyId); - const result = await svc.unbindProfile(existing.id, req.body); + const result = await svc.unbindProfile(existing.id, req.body, connectionRequestContext(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_profile_binding.deleted", entityType: "tool_profile", entityId: existing.id, @@ -2525,62 +2543,62 @@ function connectorEnrollmentPrincipal(req: Request): string { }, ); - router.get("/companies/:companyId/tools/runtime-slots", async (req, res) => { + router.get("/companies/:companyId/tools/runtime-slots", managementGuard("connections:runtime", "GET /companies/:companyId/tools/runtime-slots", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; await assertToolsRuntimeManage(req, companyId); - res.json({ runtimeSlots: await svc.listRuntimeSlots(companyId) }); + res.json({ runtimeSlots: await filterConnectionManagementRows(db, req, "connections:runtime", await svc.listRuntimeSlots(companyId), row => ({ connectionId: row.connectionId, issueId: row.issueId })) }); }); - router.post("/companies/:companyId/tools/runtime-slots/:id/stop", async (req, res) => { + router.post("/companies/:companyId/tools/runtime-slots/:id/stop", managementGuard("connections:runtime", "POST /companies/:companyId/tools/runtime-slots/:id/stop", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; await assertToolsRuntimeManage(req, companyId); - res.json(await svc.stopRuntimeSlot(companyId, req.params.id as string, getActorInfo(req))); + res.json(await svc.stopRuntimeSlot(companyId, req.params.id as string, connectionRequestActor(req))); }); - router.post("/companies/:companyId/tools/runtime-slots/:id/restart", async (req, res) => { + router.post("/companies/:companyId/tools/runtime-slots/:id/restart", managementGuard("connections:runtime", "POST /companies/:companyId/tools/runtime-slots/:id/restart", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; await assertToolsRuntimeManage(req, companyId); - res.json(await svc.restartRuntimeSlot(companyId, req.params.id as string, getActorInfo(req))); + res.json(await svc.restartRuntimeSlot(companyId, req.params.id as string, connectionRequestActor(req))); }); - router.get("/companies/:companyId/tools/runtime-health", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/runtime-health", managementGuard("connections:runtime", "GET /companies/:companyId/tools/runtime-health", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); res.json(await svc.getRuntimeHealth(companyId)); }); - router.get("/companies/:companyId/tools/runs/:runId/decisions", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/runs/:runId/decisions", managementGuard("connections:audit", "GET /companies/:companyId/tools/runs/:runId/decisions", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json(await svc.getRunDecisionLookup(companyId, req.params.runId as string)); + const result = await svc.getRunDecisionLookup(companyId, req.params.runId as string); + res.json({ ...result, decisions: await filterConnectionManagementRows(db, req, "connections:audit", result.decisions, row => ({ connectionId: row.invocation.connectionId, issueId: row.invocation.issueId })) }); }); - router.get("/companies/:companyId/tools/trust-rules", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/trust-rules", managementGuard("connections:profiles", "GET /companies/:companyId/tools/trust-rules", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json({ trustRules: await policySvc.listTrustRules(companyId) }); + res.json({ trustRules: await filterConnectionManagementPolicies(db, req, await policySvc.listTrustRules(companyId)) }); }); - router.get("/companies/:companyId/tools/policies", async (req, res) => { - assertBoard(req); + router.get("/companies/:companyId/tools/policies", managementGuard("connections:profiles", "GET /companies/:companyId/tools/policies", { personal: true }), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json({ policies: await policySvc.listPolicies(companyId) }); + res.json({ policies: await filterConnectionManagementPolicies(db, req, await policySvc.listPolicies(companyId)) }); }); // Rules UI sentence slots map exactly onto policy selectors: // capability -> riskLevel, app -> applicationId, actions -> toolNames. - router.post("/companies/:companyId/tools/policies/reorder", validate(reorderToolPoliciesSchema), async (req, res) => { + router.post("/companies/:companyId/tools/policies/reorder", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies/reorder", { personal: true }), validate(reorderToolPoliciesSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); - const policies = await policySvc.reorderPolicies(companyId, req.body); + const policies = await policySvc.reorderPolicies(companyId, req.body, connectionRequestContext(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_policy.reordered", entityType: "tool_policy", entityId: companyId, @@ -2592,15 +2610,14 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json({ policies }); }); - router.post("/companies/:companyId/tools/policies", validate(createToolPolicySchema), async (req, res) => { + router.post("/companies/:companyId/tools/policies", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies", { personal: true }), validate(createToolPolicySchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { - const policy = await policySvc.createPolicy(companyId, req.body, { userId: req.actor.userId ?? null }); + const policy = await policySvc.createPolicy(companyId, req.body, { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null }, connectionRequestContext(req)); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_policy.created", entityType: "tool_policy", entityId: policy.id, @@ -2612,20 +2629,20 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.post("/companies/:companyId/tools/policies/:policyId/duplicate", validate(duplicateToolPolicySchema), async (req, res) => { + router.post("/companies/:companyId/tools/policies/:policyId/duplicate", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies/:policyId/duplicate", { personal: true }), validate(duplicateToolPolicySchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { const policy = await policySvc.duplicatePolicy({ companyId, + managementContext: connectionRequestContext(req), policyId: req.params.policyId as string, body: req.body, - actor: { userId: req.actor.userId ?? null }, + actor: { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null }, }); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_policy.duplicated", entityType: "tool_policy", entityId: policy.id, @@ -2642,19 +2659,19 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.patch("/companies/:companyId/tools/policies/:policyId", validate(updateToolPolicySchema), async (req, res) => { + router.patch("/companies/:companyId/tools/policies/:policyId", managementGuard("connections:profiles", "PATCH /companies/:companyId/tools/policies/:policyId", { personal: true }), validate(updateToolPolicySchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { const policy = await policySvc.updatePolicy({ companyId, + managementContext: connectionRequestContext(req), policyId: req.params.policyId as string, body: req.body, }); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_policy.updated", entityType: "tool_policy", entityId: policy.id, @@ -2666,17 +2683,17 @@ function connectorEnrollmentPrincipal(req: Request): string { } }); - router.delete("/companies/:companyId/tools/policies/:policyId", async (req, res) => { + router.delete("/companies/:companyId/tools/policies/:policyId", managementGuard("connections:profiles", "DELETE /companies/:companyId/tools/policies/:policyId", { personal: true }), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); const policy = await policySvc.deletePolicy({ companyId, + managementContext: connectionRequestContext(req), policyId: req.params.policyId as string, }); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_policy.deleted", entityType: "tool_policy", entityId: policy.id, @@ -2714,19 +2731,18 @@ function connectorEnrollmentPrincipal(req: Request): string { }, ); - router.post("/companies/:companyId/tools/trust-rules/:policyId/revoke", validate(revokeToolTrustRuleSchema), async (req, res) => { + router.post("/companies/:companyId/tools/trust-rules/:policyId/revoke", managementGuard("connections:profiles", "POST /companies/:companyId/tools/trust-rules/:policyId/revoke", { personal: true }), validate(revokeToolTrustRuleSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); const policy = await policySvc.revokeTrustRule({ companyId, policyId: req.params.policyId as string, body: req.body, - actor: { userId: req.actor.userId ?? null }, + actor: { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null }, }); await logActivity(db, { companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + ...connectionRequestActor(req), action: "tool_trust_rule.revoked", entityType: "tool_policy", entityId: policy.id, @@ -2735,7 +2751,7 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(policy); }); - router.get("/companies/:companyId/tools/stdio-templates", async (req, res) => { + router.get("/companies/:companyId/tools/stdio-templates", managementGuard("connections:read", "GET /companies/:companyId/tools/stdio-templates"), async (req, res) => { const companyId = req.params.companyId as string; await assertToolsAdmin(req, companyId); res.json({ templates: await svc.approvedStdioTemplates(companyId) }); @@ -2800,11 +2816,12 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(preview); }); - router.post("/companies/:companyId/tools/policy/test", validate(toolPolicyTestRequestSchema), async (req, res) => { - assertBoard(req); + router.post("/companies/:companyId/tools/policy/test", managementGuard("connections:configure", "POST /companies/:companyId/tools/policy/test", { personal: true }), validate(toolPolicyTestRequestSchema), async (req, res) => { + assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const input = { ...req.body, companyId }; + const input = { ...req.body, companyId, + ...(req.actor.type === "agent" ? { actorType: "agent", actorId: req.actor.agentId } : {}) }; const decision = await policySvc.decide(input); let auditEvent = null; if (input.writeAuditEvent === true) { diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index 91becb88bf3..9dd4fb9425e 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -1,7 +1,8 @@ +import { filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js"; import { Router, type Request, type Response } from "express"; import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db"; +import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations, toolRuntimeSlots } from "@paperclipai/db"; import { humanizeConnectionDisplayName, type PermissionKey, @@ -349,13 +350,14 @@ function sendGatewayError(res: import("express").Response, err: unknown) { export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const router = Router(); + const managementGuard = connectionManagementGuard(db, "tool"); const access = accessService(db); async function assertBoardPermission(req: import("express").Request, companyId: string, permissionKey: PermissionKey) { - assertBoard(req); + assertBoardOrAgent(req); assertCompanyAccess(req, companyId); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - if (req.actor.userId && await access.canUser(companyId, req.actor.userId, permissionKey)) return; + if (connectionRequestUserId(req) && await access.canUser(companyId, connectionRequestUserId(req)!, permissionKey)) return; throw forbidden(`Missing permission: ${permissionKey}`); } @@ -374,18 +376,18 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } } - router.get("/companies/:companyId/tools/gateways", async (req, res) => { + router.get("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "GET /companies/:companyId/tools/gateways"), async (req, res) => { try { - await assertBoardPermission(req, req.params.companyId, "tools:admin"); - res.json({ gateways: await toolGateway.listNamedGateways(req.params.companyId) }); + await assertBoardPermission(req, (req.params.companyId as string), "tools:admin"); + res.json({ gateways: await filterConnectionManagementRows(db, req, "connections:gateway", await toolGateway.listNamedGateways((req.params.companyId as string)), row => ({ gatewayId: row.id })) }); } catch (err) { sendGatewayError(res, err); } }); - router.post("/companies/:companyId/tools/gateways", async (req, res) => { + router.post("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "POST /companies/:companyId/tools/gateways"), async (req, res) => { try { - await assertBoardPermission(req, req.params.companyId, "tools:admin"); + await assertBoardPermission(req, (req.params.companyId as string), "tools:admin"); const parsed = createToolMcpGatewaySchema.safeParse(req.body ?? {}); if (!parsed.success) { res.status(422).json({ error: "Invalid gateway payload", issues: parsed.error.issues }); @@ -393,9 +395,10 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } const actor = getActorInfo(req); const gateway = await toolGateway.createNamedGateway({ - companyId: req.params.companyId, + companyId: (req.params.companyId as string), body: parsed.data, actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null }, + managementContext: connectionRequestContext(req), }); res.status(201).json(gateway); } catch (err) { @@ -403,9 +406,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } }); - router.patch("/tool-gateway/gateways/:gatewayId", async (req, res) => { + router.patch("/tool-gateway/gateways/:gatewayId", managementGuard("connections:gateway", "PATCH /tool-gateway/gateways/:gatewayId"), async (req, res) => { try { - assertBoard(req); + assertBoardOrAgent(req); const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null; if (!companyId) { res.status(400).json({ error: "companyId is required" }); @@ -418,15 +421,15 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { return; } const { companyId: _companyId, ...body } = parsed.data as typeof parsed.data & { companyId?: string }; - res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: req.params.gatewayId, body })); + res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: (req.params.gatewayId as string), body, managementContext: connectionRequestContext(req) })); } catch (err) { sendGatewayError(res, err); } }); - router.post("/tool-gateway/gateways/:gatewayId/tokens", async (req, res) => { + router.post("/tool-gateway/gateways/:gatewayId/tokens", managementGuard("connections:gateway", "POST /tool-gateway/gateways/:gatewayId/tokens"), async (req, res) => { try { - assertBoard(req); + assertBoardOrAgent(req); const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null; if (!companyId) { res.status(400).json({ error: "companyId is required" }); @@ -441,25 +444,26 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const actor = getActorInfo(req); res.status(201).json(await toolGateway.createNamedGatewayToken({ companyId, - gatewayId: req.params.gatewayId, + gatewayId: (req.params.gatewayId as string), body: parsed.data, actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null }, + managementContext: connectionRequestContext(req), })); } catch (err) { sendGatewayError(res, err); } }); - router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", async (req, res) => { + router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", managementGuard("connections:gateway", "POST /tool-gateway/gateway-tokens/:tokenId/revoke"), async (req, res) => { try { - assertBoard(req); + assertBoardOrAgent(req); const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null; if (!companyId) { res.status(400).json({ error: "companyId is required" }); return; } await assertBoardPermission(req, companyId, "tools:admin"); - res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: req.params.tokenId })); + res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: (req.params.tokenId as string), managementContext: connectionRequestContext(req) })); } catch (err) { sendGatewayError(res, err); } @@ -470,7 +474,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { }); router.post("/tool-gateway/gateways/:gatewayId/mcp", async (req, res) => { - await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: req.params.gatewayId }); + await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: (req.params.gatewayId as string) }); }); router.post("/tool-gateway/sessions", async (req, res) => { @@ -659,22 +663,25 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } }); - router.get("/tool-gateway/runtime-slots", async (req, res) => { + router.get("/tool-gateway/runtime-slots", managementGuard("connections:runtime", "GET /tool-gateway/runtime-slots"), async (req, res) => { try { - assertBoard(req); + assertBoardOrAgent(req); const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null; if (!companyId) { res.status(400).json({ error: "companyId is required" }); return; } await assertBoardPermission(req, companyId, "tools:manage_runtime"); - res.json(await toolGateway.listRuntimeSlots(companyId)); + const slots = await toolGateway.listRuntimeSlots(companyId); + const resources = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.companyId, companyId)); + const byId = new Map(resources.map(row => [row.id, row])); + res.json(await filterConnectionManagementRows(db, req, "connections:runtime", slots.filter(row => byId.has(row.id)), row => ({ connectionId: byId.get(row.id)!.connectionId, issueId: byId.get(row.id)!.issueId }))); } catch (err) { sendGatewayError(res, err); } }); - router.post("/tool-gateway/runtime-slots/:slotId/stop", async (req, res) => { + router.post("/tool-gateway/runtime-slots/:slotId/stop", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/stop"), async (req, res) => { try { const companyId = typeof req.body?.companyId === "string" @@ -690,7 +697,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const actor = getActorInfo(req); res.json(await toolGateway.stopRuntimeSlot({ companyId, - slotId: req.params.slotId, + slotId: (req.params.slotId as string), + managementContext: connectionRequestContext(req), actor: { agentId: actor.agentId, runId: req.actor.type === "agent" ? req.actor.runId : null, @@ -701,7 +709,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } }); - router.post("/tool-gateway/runtime-slots/:slotId/restart", async (req, res) => { + router.post("/tool-gateway/runtime-slots/:slotId/restart", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/restart"), async (req, res) => { try { const companyId = typeof req.body?.companyId === "string" @@ -717,7 +725,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const actor = getActorInfo(req); res.json(await toolGateway.restartRuntimeSlot({ companyId, - slotId: req.params.slotId, + slotId: (req.params.slotId as string), + managementContext: connectionRequestContext(req), actor: { agentId: actor.agentId, runId: req.actor.type === "agent" ? req.actor.runId : null, @@ -728,9 +737,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } }); - router.get("/tool-gateway/audit", async (req, res) => { + router.get("/tool-gateway/audit", managementGuard("connections:audit", "GET /tool-gateway/audit"), async (req, res) => { try { - assertBoard(req); + assertBoardOrAgent(req); const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null; if (!companyId) { res.status(400).json({ error: "companyId is required" }); @@ -862,6 +871,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { row: toolCallEvents, invocationId: toolInvocations.id, invocationAgentId: toolInvocations.agentId, + invocationIssueId: toolInvocations.issueId, invocationApplicationId: toolInvocations.applicationId, invocationConnectionId: toolInvocations.connectionId, invocationToolName: toolInvocations.toolName, @@ -921,7 +931,12 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { return byTime !== 0 ? byTime : b.id.localeCompare(a.id); }); const hasMore = candidates.length > limit; - const visible = candidates.slice(0, limit); + const window = candidates.slice(0, limit); + const visible = await filterConnectionManagementRows(db, req, "connections:audit", window, candidate => candidate.kind === "call" + ? { connectionId: candidate.item.row.connectionId ?? candidate.item.invocationConnectionId, + issueId: candidate.item.invocationIssueId ?? candidate.item.row.issueId, + gatewayId: candidate.item.row.gatewayId } + : { connectionId: candidate.item.connectionId }); const agentIds = [...new Set(visible.flatMap((item) => [ item.kind === "call" ? item.item.row.agentId : item.item.agentId, item.kind === "call" ? item.item.invocationAgentId : null, @@ -1068,7 +1083,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { }; }); - const last = visible.at(-1); + const last = window.at(-1); res.json({ events, nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null, diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 72b18795541..5b10dac43b5 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -68,6 +68,7 @@ export interface LogActivityInput { issueId?: string | null; details?: Record | null; responsibleUserIdOverride?: string | null; + authorization?: { action: string; operation: string; reason: string }; } @@ -163,7 +164,7 @@ export function publishActivity(publication: ActivityPublication) { } export async function persistActivity(db: Db, input: LogActivityInput) { - const redactedDetails = await redactActivityDetails(db, input.details ?? null); + const redactedDetails = await redactActivityDetails(db, input.authorization ? { ...input.details, permissionDecision: input.authorization, ...(input.agentApiKeyId ? { agentApiKeyId: input.agentApiKeyId } : {}) } : input.details ?? null); const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input); const insert = (runner: Db) => runner.insert(activityLog).values({ companyId: input.companyId, diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts index 5bea58456d2..61bfdac9c2e 100644 --- a/server/src/services/ai-connection-router.ts +++ b/server/src/services/ai-connection-router.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "./connection-management.js"; import { randomUUID } from "node:crypto"; import { and, eq, ne, sql } from "drizzle-orm"; import { aiConnectionPools, aiConnectionRouterCursors, aiConnectionTaskPins, companySecrets, connectionGrants, instanceSettings, plugins, toolApplications, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db"; @@ -117,7 +118,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa } return result; } - async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string) { + async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string, managementContext?: ConnectionManagementContext) { await enabled(); const plugin = await owner(pluginKey); const config = aiConnectionPoolConfigSchema.parse(input.config); @@ -127,6 +128,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa poolMemberRuntimeConfig(member, "paperclip_runner"); } const id = input.id ?? randomUUID(); + await managementContext?.reauthorize(); await db.transaction(async (tx) => { if (input.id) { const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, id), eq(aiConnectionPools.companyId, input.companyId), eq(aiConnectionPools.pluginKey, pluginKey))).for("update"); @@ -146,13 +148,14 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa await tx.insert(aiConnectionRouterCursors).values({ poolId: id, companyId: input.companyId }); await tx.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: id, targetType: "company", targetId: input.companyId, createdByUserId: userId }); } - await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } }); + await logActivity(tx as unknown as Db, { companyId: input.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } }); }); return (await list(input.companyId)).find((p) => p.id === id)!; } - async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string) { + async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string, managementContext?: ConnectionManagementContext) { // Archive the virtual connection; cascading deletion would erase task pins. // Cleanup remains available when the experimental flag or plugin is off. + await managementContext?.reauthorize(); await db.transaction(async tx => { const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, poolId), eq(aiConnectionPools.companyId, companyId))).for("update"); if (!row) throw notFound("Connection pool not found"); @@ -161,7 +164,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa if (row.revision !== expectedRevision) throw conflict("Pool changed; reload before deleting"); await tx.update(aiConnectionPools).set({ config: { ...row.config, enabled: false }, revision: row.revision + 1, updatedAt: new Date() }).where(eq(aiConnectionPools.id, poolId)); await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(and(eq(toolConnections.id, poolId), eq(toolConnections.companyId, companyId))); - await logActivity(tx as unknown as Db, { companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } }); + await logActivity(tx as unknown as Db, { companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } }); }); return { ok: true }; } diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index 434887f4dc2..ea88cd9cca1 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -1,4 +1,5 @@ import { notifyLoginSessionChanged } from "./login-cleanup-notifications.js"; +import type { ConnectionManagementContext } from "./connection-management.js"; import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge"; import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server"; import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js"; @@ -572,7 +573,9 @@ export function aiConnectionService(db: Db) { verifiedCredential: string, loginSession?: { id: string; complete?: (tx: Db) => Promise }, attemptStartedAt = new Date(), + managementContext?: ConnectionManagementContext, ) { + await managementContext?.reauthorize(); const routing = "routing" in input ? input.routing : undefined; if (!(await membership(companyId, userId))) throw forbidden("An active company member must own this connection"); @@ -610,6 +613,7 @@ export function aiConnectionService(db: Db) { const id = reconnect?.connection.id ?? randomUUID(); const grantId = reconnect?.grant.id ?? randomUUID(); const persist = () => withAccountHomeSecretMutationLock(undefined, companyId, () => db.transaction(async (tx) => { + await managementContext?.reauthorize(); const secrets = secretService(tx); if (loginSession) { const [session] = await tx @@ -902,8 +906,7 @@ export function aiConnectionService(db: Db) { await loginSession?.complete?.(tx as unknown as Db); await logActivity(tx as unknown as Db, { companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: reconnect ? "ai_connection.reconnected" : "ai_connection.connected", diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 71d4affa174..0deca602bc7 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -54,6 +54,10 @@ function measureWorkspaceAuthorization(phase: Extract>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`, @@ -1105,11 +1125,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA .then((rows) => rows[0] ?? null); if (!row || row.companyId !== companyId || row.agentId !== agentId) return null; const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null; - const issueId = typeof context?.issueId === "string" + const issueId = row.issueId ?? (typeof context?.issueId === "string" ? context.issueId.trim() : typeof context?.taskId === "string" ? context.taskId.trim() - : ""; + : ""); return issueId || null; } @@ -1151,9 +1171,9 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA } async function loadResourceContext(resource: AuthorizationResource) { - const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null; + const issue = (resource.type === "issue" || resource.type === "connection_management") && resource.issueId ? await loadIssue(resource.issueId) : null; const projectId = - resource.type === "issue" + resource.type === "issue" || resource.type === "connection_management" ? issue?.projectId ?? resource.projectId ?? null : resource.type === "project" ? resource.projectId ?? null @@ -1168,7 +1188,12 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA companyId: string; resource: AuthorizationResource; }): Promise { - const { issue, project } = await loadResourceContext(input.resource); + const runIssueId = input.resource.type === "connection_management" + ? await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgent.id) + : null; + const { issue, project } = await loadResourceContext(runIssueId + ? { type: "issue", companyId: input.companyId, issueId: runIssueId } + : input.resource); const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id); const resolution = resolveCoreTrustPreset({ companyId: input.companyId, @@ -1302,6 +1327,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA } if ( + isConnectionManagementAction(input.action) || input.action === "company_scope:read" || // Agent creation is a company-wide privileged action. The default-on // canCreateAgents flag must never reach the legacy creator allow when @@ -2054,6 +2080,40 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA explanation: "Board user id is required.", }); } + if (isConnectionManagementAction(input.action) && input.resource.type === "connection_management") { + const membership = await getActiveMembership(companyId, "user", input.actor.userId); + if (!membership || (!input.resource.readOnly && input.action !== "connections:read" && membership.membershipRole === "viewer")) { + return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Connection management requires an active, eligible company membership." }); + } + if (input.resource.readOnly || input.action === "connections:read") { + return allow({ action: input.action, reason: "allow_company_member", explanation: "Management metadata is visible to active company members, subject to resource visibility." }); + } + if (input.resource.selfServiceUserId === input.actor.userId && + ["connections:read", "connections:configure", "connections:profiles"].includes(input.action)) { + return allow({ action: input.action, reason: "allow_self", explanation: "Allowed by personal account or connection creator authority." }); + } + if (input.action === "connections:profiles" && input.resource.selfServiceAgentInstalls) { + return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId, + action: input.action, permissionKey: "agents:configure", scope: input.scope }); + } + const userPermission = input.action === "connections:gateway" ? "tools:admin" + : input.action === "connections:profiles" && input.resource.operation.includes("/delegations") ? "tools:manage_connections" + : permissionKey!; + const granted = await decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId, + action: input.action, permissionKey: userPermission, scope: input.scope }); + // Existing human setup tests are available to connection managers. The + // acting agent still needs tools:use and normal execution policy. + if (!granted.allowed && granted.reason === "deny_missing_grant" && input.action === "connections:test") { + return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId, + action: input.action, permissionKey: "tools:manage_connections", scope: input.scope }); + } + if (!granted.allowed && granted.reason === "deny_missing_grant" && input.action === "connections:profiles" && + ["owner", "admin"].includes(membership.membershipRole ?? "")) { + return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId, + action: input.action, permissionKey: "tools:admin", scope: input.scope }); + } + return granted; + } if (taskAssignmentAction) { if (!(await assignmentTargetIsInCompany(input.resource))) { return deny({ @@ -2216,6 +2276,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA }); } + if (isConnectionManagementAction(input.action) && + (input.actor.keyScope?.kind === "skill_test" || input.actor.keyScope?.kind === "task_bridge")) { + return deny({ action: input.action, reason: "deny_scope", explanation: "Scoped test and task bridge credentials cannot administer connections." }); + } + if (input.actor.keyScope?.kind === "skill_test") { const skillTestDecision = decideSkillTestAccess({ action: input.action, @@ -2664,6 +2729,10 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA agentDecision: AuthorizationDecision, ): Promise { const responsibleUserId = input.actor.onBehalfOfUserId?.trim(); + if (input.actor.type === "agent" && isConnectionManagementAction(input.action) && !responsibleUserId) { + return deny({ action: input.action, reason: "deny_missing_membership", code: "RESPONSIBLE_USER_UNAVAILABLE", + explanation: "Connection management requires an authenticated responsible user." }); + } if ( input.actor.type !== "agent" || input.action === "inbox:manage" || @@ -2674,11 +2743,9 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA } const companyId = companyIdForResource(input.resource); - const snapshot = await getResponsibleUserSnapshot({ - actor: input.actor, - companyId, - userId: responsibleUserId, - }); + const snapshot = isConnectionManagementAction(input.action) + ? await loadResponsibleUserSnapshot(companyId, responsibleUserId) + : await getResponsibleUserSnapshot({ actor: input.actor, companyId, userId: responsibleUserId }); const denyCode: AuthorizationDecision["code"] = snapshot.userExists && snapshot.activeMembership ? "RESPONSIBLE_USER_UNAUTHORIZED" @@ -2750,7 +2817,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA responsibleUserId, }, "responsible-user authorization intersection denied"); - return input.action !== "agent_instructions:update" && responsibleUserAuthzShadowMode() ? agentDecision : denied; + return input.action !== "agent_instructions:update" && !isConnectionManagementAction(input.action) && responsibleUserAuthzShadowMode() ? agentDecision : denied; } async function applyAuthorizingUserIntersection( diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index e0af2b657e3..4613ac43484 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -6,6 +6,7 @@ import { createGitHubMaintenance } from "./chat-channels/github/maintenance.js"; import { createSpekoChannelService, authorizeSpekoPublicVoiceTask, replaceSpekoChannelRuntime, SPEKO_CONVERSATION_IS_LINEAR } from "./chat-channels/speko/runtime.js"; import { prepareDiscordRuntimeCommand } from "./chat-channels/discord/runtime-commands.js"; import { createSlackSetupServices } from "./chat-channels/slack/setup-services.js"; +import { preservesProvisionedSlackVerification } from "./chat-channels/slack/callback-verification.js"; import { isSupportedTelegramWebhookBaseUrl } from "./chat-channels/telegram/setup.js"; import { initializeGitHubWebhookRuntime } from "./chat-channels/github/webhook-runtime.js"; import { githubInboundTaskTitle, githubInboundCommentText, githubAssignedAgentRequiresLowTrust, retainGitHubDeliveryAuthority, retainGitHubReviewReceipt } from "./chat-channels/github/task-preparation.js"; @@ -13,6 +14,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"; @@ -269,6 +271,9 @@ const SUPPLIED_CREDENTIAL_KEYS: Record = { }; const MAX_ERROR_TEXT = 2_000; +const RECONNECT_CREDENTIAL_KEYS: Partial> = { + github: [...SUPPLIED_CREDENTIAL_KEYS.github, "webhookSecret", "clientSecret"], +}; // A Discord root mention is durably staged before the adapter creates its // provider thread. Give the bounded provider retry loop ample time to finish; // if the process disappears, the delivery worker verifies that thread over @@ -537,6 +542,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const { normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, legacyGitHubLabelsMatchVerifiedCredentials, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, clearCredentials, resolveCredentialRefs, resolveCredentials, prepareProviderInventory } = createChatCredentials(db, { secrets, PROVIDER_LABELS, REQUIRED_CREDENTIALS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, fetchImpl, redactError, credentialFingerprint, verifyPhotonCredentials, + preservesWebhookVerification: (endpoint, setup, credentials) => endpoint.provider === "slack" && preservesProvisionedSlackVerification( + setup, credentials.signingSecret ?? "", `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`, + ), }); const { inspectPhoton } = createPhotonSetupInspection(db, { endpointRecord, inspectPhotonCredentials }); const questionResponses = questionResponseDeliveryService(db, { @@ -3348,7 +3356,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { persistCredentials, clearCredentials, resolveCredentials, prepareProviderInventory, reconcileProviderResourceRows, reconcileDiscordCommands, runtimeFor, runtimeContextForRecord, stageTelegramMaintenance, processTelegramMaintenance, recordTelegramStopSubscription, - fetchImpl, redactError, runtimeGeneration, leasedChatProvider, PROVIDER_LABELS, SUPPLIED_CREDENTIAL_KEYS, + fetchImpl, redactError, runtimeGeneration, leasedChatProvider, PROVIDER_LABELS, SUPPLIED_CREDENTIAL_KEYS, RECONNECT_CREDENTIAL_KEYS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, isSupportedTelegramWebhookBaseUrl, get slackRegistration() { return slackRegistration; }, get githubWizard() { return githubWizard; }, @@ -3467,7 +3475,7 @@ return { configure, inspectPhoton, test, - finishSlackSetup: (endpointId: string, userId: string) => test(endpointId, { optionalSlackTestForUser: userId }), + finishSlackSetup: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => test(endpointId, { optionalSlackTestForUser: userId }, managementContext), setupTestStatus, handleWebhook, listResources, diff --git a/server/src/services/chat-channels/activity.ts b/server/src/services/chat-channels/activity.ts index 2faed157013..b56e82a14f5 100644 --- a/server/src/services/chat-channels/activity.ts +++ b/server/src/services/chat-channels/activity.ts @@ -1,3 +1,4 @@ +import { connectionManagementService, type ConnectionManagementContext } from "../connection-management.js"; import { providerEffectPayload } from "./provider-effects.js"; import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js"; @@ -161,7 +162,7 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) { }); } - async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) { + async function readActivity(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,7 +333,38 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) { ) cancellableConflicts.set(publication.id, transfer.version); } - return [ + // An endpoint manager may still lack access to individual private tasks. + let visibleIds: Set | undefined; + if (managementContext?.actor.type === "agent") { + const activityRows = [ + ...deliveries, ...publications, ...slashActions, ...providerEffects, + ...githubIngressFailures, ...slackSessionActions, ...githubRecoveries.map(row => row.action), + ]; + // Each source is page-bounded above. Endpoint history does not contribute + // authorization work unless a conversation appears in these candidates. + const conversationIds = [...new Set(activityRows.flatMap(row => row.conversationId ? [row.conversationId] : []))]; + const conversations = conversationIds.length ? await db.select({ id: chatConversations.id, issueId: chatConversations.issueId }).from(chatConversations).where(and( + eq(chatConversations.companyId, managementContext.resource.companyId), + eq(chatConversations.endpointId, endpointId), inArray(chatConversations.id, conversationIds), + )) : []; + const visibleConversations = new Set(); + const issueVisibility = new Map(); + const management = connectionManagementService(db); + for (const conversation of conversations) { + let allowed = issueVisibility.get(conversation.issueId); + if (allowed === undefined) { + const decision = await management.capability(managementContext.actor, "connections:audit", + { ...managementContext.resource, issueId: conversation.issueId }, { mutation: false, readContext: managementContext }); + allowed = decision.allowed; + issueVisibility.set(conversation.issueId, allowed); + } + if (allowed) visibleConversations.add(conversation.id); + } + const visible = (row: { id: string; conversationId: string | null }) => !row.conversationId || visibleConversations.has(row.conversationId); + visibleIds = new Set(activityRows.filter(visible).map(row => row.id)); + githubRecoveryScans.forEach(row => visibleIds!.add(row.id)); + } + const rows = [ ...deliveries.map((row) => { const normalized = row.normalizedEvent && @@ -550,11 +582,18 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) { })), ...githubRecoveryScans, ] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) - .slice(0, limit); + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)); + return { + visible: rows.filter(row => !visibleIds || visibleIds.has(row.id)).slice(0, limit), + examined: rows.slice(0, limit), + }; } - async function listActivityPage(endpointId: string, limit = 25, cursor?: string) { + async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }, managementContext?: ConnectionManagementContext) { + return (await readActivity(endpointId, page, managementContext)).visible; + } + + 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,12 +606,14 @@ 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 items = rows.slice(0, limit); - const last = items.at(-1); + const rows = await readActivity(endpointId, { limit, before }, managementContext); + const examined = rows.examined.slice(0, limit); + const ids = new Set(examined.map(row => row.id)); + const items = rows.visible.filter(row => ids.has(row.id)); + const last = examined.at(-1); return { items, - nextCursor: rows.length > limit && last + nextCursor: rows.examined.length > limit && last ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url") : null, }; diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts index 80fbaafd756..73c1f84dbee 100644 --- a/server/src/services/chat-channels/configuration.ts +++ b/server/src/services/chat-channels/configuration.ts @@ -1,3 +1,5 @@ +import type { ConnectionManagementContext } from "../connection-management.js"; +import { CHAT_HUMAN_CREDENTIAL_FIELDS } from "./credentials.js"; import { preparePhotonConfiguration, createPhotonConfiguration } from "./imessage-photon/setup.js"; import { slackRuntimeConfig } from "./slack/setup.js"; import { createTelegramSetup } from "./telegram/setup.js"; @@ -7,9 +9,7 @@ import { createGitHubAppSetup } from "./github/app-setup.js"; import type { VerifiedProviderIdentity } from "./runtime-types.js"; -import { isProvisionedSlackSetup } from "@paperclipai/shared"; - -import { slackCallbackMatchesPublicUrl } from "./slack/callback-verification.js"; +import { preservesProvisionedSlackVerification } from "./slack/callback-verification.js"; import { createDiscordCommands } from "./discord/commands.js"; @@ -26,9 +26,6 @@ import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from " import { slackChatRegistrationService } from "../chat-slack-registration.js"; -import { - createHash, -} from "node:crypto"; import { and, eq, @@ -61,6 +58,7 @@ import { import { badRequest, conflict, + forbidden, notFound, unprocessable, } from "../../errors.js"; @@ -93,6 +91,7 @@ type ChatConfigurationOptions = Pick, " leasedChatProvider: (provider: string) => boolean; PROVIDER_LABELS: Record; SUPPLIED_CREDENTIAL_KEYS: Record; + RECONNECT_CREDENTIAL_KEYS?: Partial>; PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number; isSupportedTelegramWebhookBaseUrl: (url: string | null) => boolean; slackRegistration: Pick, "cleanup">; @@ -113,7 +112,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"); @@ -124,7 +125,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption input.action === "configure" || input.action === "reconnect"; const allowedKeys = new Set( credentialAction - ? SUPPLIED_CREDENTIAL_KEYS[record.endpoint.provider] + ? (input.action === "reconnect" ? options.RECONNECT_CREDENTIAL_KEYS?.[record.endpoint.provider] : undefined) ?? SUPPLIED_CREDENTIAL_KEYS[record.endpoint.provider] : [], ); const unsupportedKeys = suppliedCredentialKeys.filter( @@ -141,6 +142,15 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption }, ); } + // These keys establish provider authentication or inbound trust. They + // must come from the human setup step or a verified provider handoff. + const credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(key => suppliedCredentialKeys.includes(key)); + if (managementContext?.actor.type === "agent" && credentialFields.length) { + throw forbidden("The responsible user must enter provider verification credentials in the saved setup", { + code: "chat_endpoint_credential_entry_requires_human", + credentialFields, + }); + } } return withCredentialMutationLease(record.endpoint, (credentialLease) => configureWithCredentialLease( @@ -148,6 +158,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption input, credentialLease, actorUserId, + managementContext, ), ); } @@ -157,7 +168,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 +180,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 +233,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, @@ -269,6 +282,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption prepared.credentials, credentialLease, actorUserId, + managementContext ? async () => managementContext.reauthorize() : undefined, ); } credentials = prepared.credentials; @@ -280,7 +294,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 +377,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 +431,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 +469,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 @@ -658,6 +674,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption `${PROVIDER_LABELS[endpoint.provider]} credentials are required`, ); }); + await managementContext?.reauthorize(); if (endpoint.provider === "imessage-photon") { credentials = preparePhotonConfiguration(endpoint, input, credentials); } @@ -696,6 +713,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); @@ -750,6 +768,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption credentials, credentialLease, actorUserId, + managementContext ? async () => managementContext.reauthorize() : undefined, ); if (prepared.inventory) await reconcileProviderResourceRows( @@ -762,6 +781,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 @@ -774,10 +794,7 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption if (endpoint.provider === "speko") { await installSpekoVoiceTools(tx, endpoint, actorUserId); } - const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; - const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) - && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") - && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); + const preserveSlackVerification = preservesProvisionedSlackVerification(current.setup, credentials.signingSecret ?? "", `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`); await notifyChatEndpointWork(tx, endpoint.connectionId); await tx .update(chatEndpoints) @@ -861,6 +878,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 +906,12 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption throw unprocessable(failure, { code: "chat_provider_setup_failed" }); } - await db.transaction(async (tx) => { + await managementContext?.reauthorize(); + await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); await logActivity(tx as unknown as Db, { companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }), action: input.action === "reconnect" ? "chat_endpoint.reconnected" diff --git a/server/src/services/chat-channels/credentials.ts b/server/src/services/chat-channels/credentials.ts index b31810ed7d9..51ff81a807a 100644 --- a/server/src/services/chat-channels/credentials.ts +++ b/server/src/services/chat-channels/credentials.ts @@ -16,7 +16,8 @@ import { type CredentialMutationLeaseGuard } from "../chat-credential-mutation-l import { photonSharedScope } from "../photon/cloud.js"; -import { photonChannelConfigurationSchema } from "@paperclipai/shared"; +import { isUuidLike, photonChannelConfigurationSchema } from "@paperclipai/shared"; +import { notifyChatEndpointWork } from "../chat-work-notifications.js"; import { randomBytes, @@ -27,6 +28,7 @@ import { eq, inArray, ne, + sql, } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -44,6 +46,7 @@ import type { import { isUniqueViolation } from "../../db-errors.js"; import { + HttpError, conflict, notFound, unprocessable, @@ -57,6 +60,18 @@ import { } from "../chat-teams-credentials.js"; import { secretService } from "../secrets.js"; +import { isSecretProviderClientError } from "../../secrets/types.js"; + +export const CHAT_HUMAN_CREDENTIAL_FIELDS = ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"] as const; + +/** Opaque generations identify actual protected-value changes, never secret bytes. */ +export function chatCredentialEntryGenerations(setup: object): Record { + const generations: unknown = Reflect.get(setup, "credentialEntryGenerations"); + return Object.fromEntries(CHAT_HUMAN_CREDENTIAL_FIELDS.map(field => { + const value: unknown = generations && typeof generations === "object" ? Reflect.get(generations, field) : null; + return [field, typeof value === "string" && isUuidLike(value) ? value : null]; + })); +} type ChatCredentialsOptions = { secrets: ReturnType; @@ -67,6 +82,7 @@ type ChatCredentialsOptions = { redactError: (error: unknown) => string; credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; verifyPhotonCredentials: ReturnType["verifyPhotonCredentials"]; + preservesWebhookVerification?: (endpoint: EndpointRow, setup: EndpointRow["setup"], credentials: Record) => boolean; }; export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { const { secrets, PROVIDER_LABELS, REQUIRED_CREDENTIALS, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS, fetchImpl, redactError, credentialFingerprint, verifyPhotonCredentials } = options; @@ -253,6 +269,7 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { credentials: Record, credentialLease: CredentialMutationLeaseGuard, actorUserId?: string | null, + onCommitted?: (tx: Db) => Promise, ) { await credentialLease.assertOwned(); const previousRefs = await db @@ -265,6 +282,25 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { ), ) .then((rows) => rows[0]?.refs ?? []); + const protectedFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => credentials[field] !== undefined); + const previousValues: Record = {}; + for (const ref of previousRefs.filter(ref => protectedFields.some(field => ref.configPath === `credentials.${field}`))) { + try { + Object.assign(previousValues, await resolveCredentialRefs(endpoint, [ref])); + } catch (error) { + // A valid replacement can repair unavailable old material. Unexpected + // database failures and company/scope violations must still abort. + const code = error instanceof HttpError && error.details && typeof error.details === "object" + ? Reflect.get(error.details, "code") : null; + const unavailable = isSecretProviderClientError(error) || (error instanceof HttpError && ( + ["secret_deleted", "secret_inactive", "version_missing", "version_inactive"].includes(code) || + (error.status === 404 && error.message === "Secret not found" && !error.details) + )); + if (!unavailable) throw error; + } + } + const changedFields = protectedFields.filter(field => credentials[field] !== previousValues[field]); + const generations = Object.fromEntries(changedFields.map(field => [field, randomUUID()])); const refs: ToolCredentialSecretRef[] = []; try { for (const [key, value] of Object.entries(credentials)) { @@ -344,6 +380,21 @@ export function createChatCredentials(db: Db, options: ChatCredentialsOptions) { .update(toolConnections) .set({ credentialSecretRefs: refs, updatedAt: new Date() }) .where(eq(toolConnections.id, endpoint.connectionId)); + if (changedFields.length) { + await notifyChatEndpointWork(tx, endpoint.connectionId); + const [currentEndpoint] = await tx.select({ setup: chatEndpoints.setup }).from(chatEndpoints) + .where(and(eq(chatEndpoints.id, endpoint.id), eq(chatEndpoints.companyId, endpoint.companyId))).for("update"); + if (!currentEndpoint) throw notFound("Chat endpoint not found"); + const verification = changedFields.some(field => field === "signingSecret" || field === "webhookSecret") + && !options.preservesWebhookVerification?.(endpoint, currentEndpoint.setup, credentials) + ? { webhookVerifiedAt: null } : {}; + await tx.update(chatEndpoints).set({ + // Fence old healthy state in the same transaction as the new refs. + status: sql`case when ${chatEndpoints.status} = 'active' then 'verifying' else ${chatEndpoints.status} end`, + setup: sql`jsonb_set(${chatEndpoints.setup}, '{credentialEntryGenerations}', coalesce(${chatEndpoints.setup}->'credentialEntryGenerations', '{}'::jsonb) || ${JSON.stringify(generations)}::jsonb) || ${JSON.stringify(verification)}::jsonb`, + }).where(and(eq(chatEndpoints.id, endpoint.id), eq(chatEndpoints.companyId, endpoint.companyId))); + } + await onCommitted?.(tx as unknown as Db); await credentialLease.assertOwned(tx); }); } catch (error) { diff --git a/server/src/services/chat-channels/endpoints.ts b/server/src/services/chat-channels/endpoints.ts index 842860e1036..4d09b7b5d85 100644 --- a/server/src/services/chat-channels/endpoints.ts +++ b/server/src/services/chat-channels/endpoints.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "../connection-management.js"; import { slackSetupState } from "./slack/setup.js"; import { assertChatExecutionDefaultsAccess } from "../chat-execution-defaults.js"; @@ -37,6 +38,7 @@ import type { } from "@paperclipai/shared"; import { isAgentStatusInvokable, + isUuidLike, } from "@paperclipai/shared"; import { @@ -84,6 +86,7 @@ function providerSetupState( const path = endpoint.provider === "speko" ? `/api/voice-webhooks/${endpoint.publicId}/tools` : `/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`; const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null; const step = endpoint.status === "active" ? "complete" : endpoint.setup.step; + const rotationId: unknown = Reflect.get(endpoint.setup, "credentialRotationId"); switch (endpoint.provider) { case "speko": return { step, providerUrl: "https://platform.speko.ai/", webhookUrl } as const; @@ -102,6 +105,7 @@ function providerSetupState( providerUrl: "https://github.com/settings/installations", webhookUrl, webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, + credentialRotationId: typeof rotationId === "string" && isUuidLike(rotationId) ? rotationId : null, } as const; case "discord": return { @@ -240,7 +244,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 +279,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 +369,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 +386,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 +450,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 +468,7 @@ function providerSetupState( .where(eq(chatEndpoints.id, endpointId)); await logActivity(tx as unknown as Db, { companyId: existing.endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }), action: "chat_endpoint.updated", entityType: "tool_connection", entityId: existing.endpoint.connectionId, diff --git a/server/src/services/chat-channels/github/app-setup.ts b/server/src/services/chat-channels/github/app-setup.ts index 55466475cda..2697bb11b12 100644 --- a/server/src/services/chat-channels/github/app-setup.ts +++ b/server/src/services/chat-channels/github/app-setup.ts @@ -1,4 +1,5 @@ import type { ChatEndpointSetupState } from "@paperclipai/shared"; +import type { ConnectionManagementContext } from "../../connection-management.js"; import type { VerifiedProviderIdentity } from "../runtime-types.js"; import { createSetupProbes } from "../setup-probes.js"; @@ -77,8 +78,8 @@ type GitHubAppSetupOptions = { reconcileProviderResourceRows: ReturnType["reconcileProviderResourceRows"]; listResources: ReturnType["listResources"]; replaceResources: ReturnType["replaceResources"]; - update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null) => Promise; - configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null) => Promise; + update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise; + configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise; test: ReturnType["test"]; githubWizardOrigin?: string | null; handleWebhook: (publicId: string, provider: "github", request: Request) => Promise; @@ -88,7 +89,9 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) { const { endpointRecord, withCredentialMutationLease, normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, resolveCredentials, fetchImpl, get, getPublicBaseUrl, getWebhookPublicBaseUrl, prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook } = options; - async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app") { + async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app", managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); + if (managementContext?.actor.type === "agent") throw forbidden("GitHub signing credentials require a human setup handoff"); 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 +101,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 +123,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 +151,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 +172,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 +184,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 +195,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 +207,11 @@ export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) { }); }, refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources, - configure: async (id, userId) => { - await update(id, { allowGroupChats: true }, userId); - return configure(id, { action: "configure" }, userId); + configure: async (id, userId, context) => { + await update(id, { allowGroupChats: true }, userId, context); + return configure(id, { action: "configure" }, userId, context); }, - finish: (id, userId) => test(id, { optionalSlackTestForUser: userId }), + finish: (id, userId, context) => test(id, { optionalSlackTestForUser: userId }, context), }); const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress( db, diff --git a/server/src/services/chat-channels/github/setup-secret.ts b/server/src/services/chat-channels/github/setup-secret.ts index 4fb2cb43fe3..5844c69da16 100644 --- a/server/src/services/chat-channels/github/setup-secret.ts +++ b/server/src/services/chat-channels/github/setup-secret.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "../../connection-management.js"; import type { EndpointRow, InternalSetupState } from "../runtime-types.js"; import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; @@ -9,6 +10,7 @@ import { import { and, eq, + sql, } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -25,6 +27,7 @@ import type { import { conflict, + forbidden, notFound, unprocessable, } from "../../../errors.js"; @@ -40,7 +43,7 @@ type GitHubSetupSecretOptions = { endpointRecord: (endpointId: string) => Promise; withCredentialMutationLease: ReturnType; resolveCredentials: (endpoint: EndpointRow) => Promise>; - persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise; + persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null, onCommitted?: (tx: Db) => Promise) => Promise; runtimeGeneration: (setup: ChatEndpointSetupState) => number; credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; invalidateRuntime: (endpointId: string) => Promise; @@ -57,7 +60,12 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption async function generateSetupSecret( endpointId: string, actorUserId?: string | null, + managementContext?: ConnectionManagementContext, ) { + await managementContext?.reauthorize(); + if (managementContext?.actor.type === "agent") { + throw forbidden("Webhook signing secrets require a human setup handoff"); + } const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); if (initial.endpoint.provider !== "github") { @@ -123,23 +131,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 +186,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 +197,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 +232,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?.(); @@ -232,6 +245,11 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption { ...existing, webhookSecret }, credentialLease, actorUserId, + async (tx) => { + await tx.update(chatEndpoints).set({ + setup: sql`${chatEndpoints.setup} || ${JSON.stringify({ credentialRotationId: rotationId, webhookVerifiedAt: null })}::jsonb`, + }).where(eq(chatEndpoints.id, endpoint.id)); + }, ); committedWebhookSecretRef = persistedRefs.find( @@ -245,8 +263,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 +282,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( @@ -287,7 +305,7 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption "GitHub setup secret rotated but completion audit failed", ); }); - return { webhookSecret }; + return { webhookSecret, credentialRotationId: rotationId }; }, { beforeFinalOwnershipCheck: options.setupSecretFinalOwnershipBarrier, diff --git a/server/src/services/chat-channels/resource-reconciliation.ts b/server/src/services/chat-channels/resource-reconciliation.ts index 66a82e76ff3..0869aff07e4 100644 --- a/server/src/services/chat-channels/resource-reconciliation.ts +++ b/server/src/services/chat-channels/resource-reconciliation.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "../connection-management.js"; import { imessageResourceParticipants } from "./imessage-photon/resources.js"; import { createGitHubResources, reconcileGitHubRepositoryCoordinates } from "./github/resources.js"; import { baseTeamsConversationId } from "./microsoft-teams/resources.js"; @@ -166,7 +167,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc endpointId: string, updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>, actorUserId?: string | null, - options?: { initialGitHubImport?: boolean }, + options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext }, ) { await updateResourceSelection(endpointId, updates, actorUserId, options); return listResources(endpointId); @@ -176,8 +177,9 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc endpointId: string, updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>, actorUserId?: string | null, - options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean }, + options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean; managementContext?: ConnectionManagementContext }, ) { + await options?.managementContext?.reauthorize(); const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); const bulk = options?.allGitHubRepositoriesEnabled !== undefined; @@ -263,7 +265,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc await assertChatExecutionDefaultsAccess(db, { companyId: endpoint.companyId, assigneeAgentId: endpoint.assignedAgentId, - actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" }, + actor: options?.managementContext?.actor ?? { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" }, defaults: entry.executionDefaults, }, tx); finalDefaults.set(entry.id, entry.executionDefaults); @@ -306,8 +308,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc tx as unknown as Db, { companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", + ...(options?.managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }), action: "chat_endpoint.resources_updated", entityType: "tool_connection", entityId: endpoint.connectionId, diff --git a/server/src/services/chat-channels/setup-probes.ts b/server/src/services/chat-channels/setup-probes.ts index 3aeb21f8833..74aec70c43e 100644 --- a/server/src/services/chat-channels/setup-probes.ts +++ b/server/src/services/chat-channels/setup-probes.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "../connection-management.js"; import type { EndpointRow, DbOrTransaction } from "./runtime-types.js"; import { notifyChatEndpointWork } from "../chat-work-notifications.js"; @@ -94,7 +95,8 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) { return { messageReceivedAt: at?.toISOString() ?? null }; } - async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) { + async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); return withCredentialMutationLease( @@ -287,6 +289,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) { } } await options.setupTestActivationBarrier?.(); + await managementContext?.reauthorize(); const expectedGeneration = runtimeGeneration(endpoint.setup); await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); @@ -339,7 +342,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) { }) .where(eq(toolConnections.id, endpoint.connectionId)); if (optionalSlackTest) await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser, + companyId: endpoint.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: finishOptions!.optionalSlackTestForUser }), action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId, details: { conversationTestOptional: true }, }); diff --git a/server/src/services/chat-channels/slack/callback-verification.ts b/server/src/services/chat-channels/slack/callback-verification.ts index ecddb2f665f..ca6e4ed7286 100644 --- a/server/src/services/chat-channels/slack/callback-verification.ts +++ b/server/src/services/chat-channels/slack/callback-verification.ts @@ -34,6 +34,15 @@ import { import { secretService } from "../../secrets.js"; +/** A signed provider challenge remains valid when those exact credentials enter the runtime vault. */ +export function preservesProvisionedSlackVerification(setup: EndpointRow["setup"], signingSecret: string, webhookUrl: string): boolean { + const internal = setup as InternalSetupState; + const observedUrl = internal.slackCallbackSurfaces?.events?.url; + return !!setup.webhookVerifiedAt && isProvisionedSlackSetup(setup.slackSetupMethod) + && internal.slackVerificationSigningFingerprint === createHash("sha256").update(signingSecret).digest("hex") + && !!observedUrl && slackCallbackMatchesPublicUrl(observedUrl, webhookUrl); +} + function slackRequestSignatureIsValid( request: Request, body: string, diff --git a/server/src/services/chat-github-management.ts b/server/src/services/chat-github-management.ts index c4ba12717d0..c4bf27e1330 100644 --- a/server/src/services/chat-github-management.ts +++ b/server/src/services/chat-github-management.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "./connection-management.js"; import { githubBotCredentials } from "./chat-github-client.js"; import { toolAccessPolicyService } from "./tool-access-policy.js"; import { agents, toolCatalogEntries } from "@paperclipai/db"; @@ -90,7 +91,9 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { endpointId: string, input: unknown, userId: string, + managementContext?: ConnectionManagementContext, ) { + await managementContext?.reauthorize(); const bot = await endpoint(endpointId); await member(bot.companyId, userId); const parsed = updateGitHubChatConfigurationSchema.parse(input); @@ -185,8 +188,7 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) { await syncGitHubBotTools(tx, bot, userId, config.toolsEnabled); await logActivity(tx as unknown as Db, { companyId: bot.companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.configuration_updated", entityType: "tool_connection", entityId: bot.connectionId, diff --git a/server/src/services/chat-github-registration.ts b/server/src/services/chat-github-registration.ts index ae5019e17b4..43f7c2f864e 100644 --- a/server/src/services/chat-github-registration.ts +++ b/server/src/services/chat-github-registration.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "./connection-management.js"; import { createHash, randomBytes } from "node:crypto"; import { and, eq, gt, sql } from "drizzle-orm"; import { @@ -52,6 +53,7 @@ export function githubChatRegistrationService( clientId?: string; clientSecret?: string; }, + managementContext?: ConnectionManagementContext, ) => Promise; }, ) { @@ -59,7 +61,9 @@ export function githubChatRegistrationService( endpointId: string, userId: string, input: string | GitHubAppRegistrationInput, + managementContext?: ConnectionManagementContext, ) { + await managementContext?.reauthorize(); const { name, ownerType = "personal", @@ -111,6 +115,7 @@ export function githubChatRegistrationService( .where(eq(companies.id, endpoint.companyId)); const state = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + 30 * 60_000); + await managementContext?.reauthorize(); await db.transaction(async (tx) => { const [lockedEndpoint] = await tx .select() @@ -151,6 +156,7 @@ export function githubChatRegistrationService( appName: name.trim(), handoff: { cloudId: "", + ...(managementContext?.actor.type === "board" && managementContext.actor.sessionId ? { browserSessionId: managementContext.actor.sessionId } : {}), redemptionId: randomBytes(32).toString("base64url"), returnState: state, }, @@ -158,8 +164,7 @@ export function githubChatRegistrationService( }); await logActivity(tx as unknown as Db, { companyId: endpoint.companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.registration_started", entityType: "tool_connection", entityId: endpoint.connectionId, @@ -234,7 +239,8 @@ export function githubChatRegistrationService( }, }; } - async function resumeRegistration(endpointId: string, userId: string) { + async function resumeRegistration(endpointId: string, userId: string, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); const [session] = await db .select() .from(chatGitHubRegistrations) @@ -277,7 +283,8 @@ export function githubChatRegistrationService( expiresAt: session.expiresAt, }); } - async function complete(state: string, code: string) { + async function complete(state: string, code: string, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); if ( !/^[A-Za-z0-9_-]{43}$/.test(state) || !/^[a-zA-Z0-9_-]{1,256}$/.test(code) @@ -353,6 +360,7 @@ export function githubChatRegistrationService( app.owner?.login?.toLowerCase() !== session.ownerLogin?.toLowerCase()) ) throw badRequest("GitHub returned an App owned by a different account"); + await managementContext?.reauthorize(); await options.storeApp(session.endpointId, session.userId, { appId: String(app.id), privateKey: app.pem, @@ -360,7 +368,7 @@ export function githubChatRegistrationService( slug: app.slug, clientId: app.client_id, clientSecret: app.client_secret, - }); + }, managementContext); await db .update(chatGitHubRegistrations) .set({ status: "completed" }) diff --git a/server/src/services/chat-github-wizard.ts b/server/src/services/chat-github-wizard.ts index 701d77cba51..abe1e3a6754 100644 --- a/server/src/services/chat-github-wizard.ts +++ b/server/src/services/chat-github-wizard.ts @@ -1,3 +1,5 @@ +import type { AuthorizationActor } from "./authorization.js"; +import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js"; import { createHash, randomBytes } from "node:crypto"; import { and, desc, eq, sql } from "drizzle-orm"; import { @@ -73,6 +75,7 @@ export function githubChatWizardService( endpointId: string, userId: string, input: GitHubAppRegistrationInput, + managementContext?: ConnectionManagementContext, ) => Promise<{ registrationUrl: string; manifest: Record; @@ -81,16 +84,18 @@ export function githubChatWizardService( resumeDirect?: ( endpointId: string, userId: string, + managementContext?: ConnectionManagementContext, ) => Promise<{ registrationUrl: string; manifest: Record; expiresAt: string; }>; - completeDirect?: (state: string, code: string) => Promise; + completeDirect?: (state: string, code: string, managementContext?: ConnectionManagementContext) => Promise; storeApp: ( endpointId: string, userId: string, credentials: Record, + managementContext?: ConnectionManagementContext, ) => Promise; storeCredentials: ( endpointId: string, @@ -100,16 +105,17 @@ export function githubChatWizardService( refreshRepositories: ( endpointId: string, userId: string, + managementContext?: ConnectionManagementContext, ) => Promise; resources: (endpointId: string) => Promise; replaceResources: ( endpointId: string, updates: Array<{ id: string; enabled: boolean }>, userId: string, - options?: { initialGitHubImport?: boolean }, + options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext }, ) => Promise; - configure: (endpointId: string, userId: string) => Promise; - finish: (endpointId: string, userId: string) => Promise; + configure: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise; + finish: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise; }, ) { const fetchImpl = options.fetch ?? fetch; @@ -214,8 +220,9 @@ export function githubChatWizardService( async function start( id: string, userId: string, - input: GitHubAppRegistrationInput, + input: GitHubAppRegistrationInput, managementContext?: ConnectionManagementContext, ) { + await managementContext?.reauthorize(); const bot = await endpoint(id, userId); if (bot.botExternalId) throw conflict( @@ -229,13 +236,13 @@ export function githubChatWizardService( throw conflict( "Recover the existing App from GitHub settings; its registration cannot safely be repeated.", ); - if (prior.handoff) return advance(id, userId); + if (prior.handoff) return advance(id, userId, managementContext); // The direct flow's hashed state cannot be reconstructed. Do not create a duplicate App. throw conflict( "An App registration is already pending. Complete its open GitHub page, or use existing-App recovery.", ); } - await saveDraft(id, userId, input); + await saveDraft(id, userId, input, managementContext); // Only a fresh registration may import GitHub's initial selection. Old // drafts without this marker retain even an entirely disabled selection. if ( @@ -249,7 +256,7 @@ export function githubChatWizardService( return { endpointId: id, state: "create", - registration: await options.startDirect(id, userId, input), + registration: await options.startDirect(id, userId, input, managementContext), } satisfies GitHubAppWizardState; return { endpointId: id, @@ -302,13 +309,12 @@ export function githubChatWizardService( ownerLogin: input.ownerLogin, appName: input.name, expiresAt: new Date(Date.now() + 30 * 60_000), - handoff: { cloudId: nonce(), returnState, redemptionId: nonce() }, + handoff: { cloudId: nonce(), returnState, redemptionId: nonce(), ...(managementContext?.actor.type === "board" && managementContext.actor.sessionId ? { browserSessionId: managementContext.actor.sessionId } : {}) }, }) .returning(); await logActivity(tx as unknown as Db, { companyId: bot.companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.registration_started", entityType: "tool_connection", entityId: bot.connectionId, @@ -403,6 +409,7 @@ export function githubChatWizardService( async function exchangeCloudManifest( session: typeof chatGitHubRegistrations.$inferSelect, claimId: string, + managementContext?: ConnectionManagementContext, ) { const cloud = connector(); if (!cloud || !session.handoff) @@ -471,6 +478,7 @@ export function githubChatWizardService( app.owner.login?.toLowerCase() !== session.ownerLogin.toLowerCase()) ) throw forbidden("GitHub returned an App owned by a different account"); + await managementContext?.reauthorize(); await options.storeApp(session.endpointId, session.userId, { appId: String(app.id), privateKey: app.pem, @@ -478,7 +486,7 @@ export function githubChatWizardService( clientId: app.client_id, clientSecret: app.client_secret, slug: app.slug, - }); + }, managementContext); await db .update(chatGitHubRegistrations) .set({ @@ -500,7 +508,8 @@ export function githubChatWizardService( session.expiresAt <= new Date() && !session.consumedAt && !session.handoff.manifestClaimId; } - async function restartRegistration(id: string, userId: string, registrationId: string) { + async function restartRegistration(id: string, userId: string, registrationId: string, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); const bot = await endpoint(id, userId); const cloud = connector(); if (!cloud) throw conflict("Restore Paperclip Cloud enrollment before continuing"); @@ -532,20 +541,21 @@ export function githubChatWizardService( handoff: { cloudId: nonce(), returnState, redemptionId: nonce(), renewalOf: prior.id }, }).returning(); await logActivity(tx as unknown as Db, { - companyId: bot.companyId, actorType: "user", actorId: userId, + companyId: bot.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.registration_restarted", entityType: "tool_connection", entityId: bot.connectionId, details: { endpointId: id, registrationId: next!.id, previousRegistrationId: prior.id, appNotCreated: true }, }); return next!; }); await setup(bot, { cloudRegistrationId: created.handoff!.cloudId, registrationStatus: "pending", stage: "connect" }); - return advance(id, userId); + return advance(id, userId, managementContext); } async function saveDraft( id: string, userId: string, - input: GitHubAppRegistrationInput, + input: GitHubAppRegistrationInput, managementContext?: ConnectionManagementContext, ) { + await managementContext?.reauthorize(); const bot = await endpoint(id, userId); await db.transaction(async (tx) => { await tx @@ -571,8 +581,7 @@ export function githubChatWizardService( .where(eq(chatEndpoints.id, id)); await logActivity(tx as unknown as Db, { companyId: bot.companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.draft_saved", entityType: "tool_connection", entityId: bot.connectionId, @@ -581,7 +590,8 @@ export function githubChatWizardService( }); return { saved: true as const }; } - async function repairCloud(id: string, userId: string) { + async function repairCloud(id: string, userId: string, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); const bot = await endpoint(id, userId), session = await registration(bot), cloud = connector(); @@ -608,8 +618,7 @@ export function githubChatWizardService( throw conflict("Restore this App's Cloud gateway before continuing"); await logActivity(db, { companyId: bot.companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.webhook_repair_started", entityType: "tool_connection", entityId: bot.connectionId, @@ -659,8 +668,7 @@ export function githubChatWizardService( } await logActivity(db, { companyId: bot.companyId, - actorType: "user", - actorId: userId, + ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.webhook_repaired", entityType: "tool_connection", entityId: bot.connectionId, @@ -669,12 +677,23 @@ export function githubChatWizardService( } async function advance( id: string, - userId: string, + userId: string, managementContext?: ConnectionManagementContext, ): Promise { + await managementContext?.reauthorize(); + // Bind an agent-created handoff when its addressed human opens the saved setup. + if (managementContext?.actor.type === "board" && managementContext.actor.sessionId) { + const bot = await endpoint(id, userId); + const session = await registration(bot); + if (session && session.userId === userId && !session.handoff?.browserSessionId) { + await db.update(chatGitHubRegistrations).set({ + handoff: sql`coalesce(${chatGitHubRegistrations.handoff}, '{}'::jsonb) || ${JSON.stringify({ browserSessionId: managementContext.actor.sessionId })}::jsonb`, + }).where(and(eq(chatGitHubRegistrations.id, session.id), sql`${chatGitHubRegistrations.handoff}->>'browserSessionId' is null`)); + } + } // Multiple open tabs for this manager share one operation, not concurrent vault writes. const key = `${id}:${userId}`; if (pending.has(key)) return pending.get(key)!; - const work = advanceOnce(id, userId); + const work = advanceOnce(id, userId, managementContext); pending.set(key, work); try { return await work; @@ -684,8 +703,9 @@ export function githubChatWizardService( } async function advanceOnce( id: string, - userId: string, + userId: string, managementContext?: ConnectionManagementContext, ): Promise { + await managementContext?.reauthorize(); let bot = await endpoint(id, userId); if (["paused", "revoked"].includes(bot.status)) return { @@ -737,7 +757,7 @@ export function githubChatWizardService( session.handoff.manifestClaimId && !hasAppCredentials ) { - await exchangeCloudManifest(session, session.handoff.manifestClaimId); + await exchangeCloudManifest(session, session.handoff.manifestClaimId, managementContext); bot = await endpoint(id, userId); const [savedConnection] = await db .select({ refs: toolConnections.credentialSecretRefs }) @@ -790,7 +810,7 @@ export function githubChatWizardService( session.handoff.webhookSecretHash !== hash(stored.webhookSecret ?? "")) ) { - await repairCloud(id, userId); + await repairCloud(id, userId, managementContext); bot = await endpoint(id, userId); } if (session.status !== "completed") @@ -826,7 +846,7 @@ export function githubChatWizardService( return { endpointId: id, state: "create", - registration: await options.resumeDirect(id, userId), + registration: await options.resumeDirect(id, userId, managementContext), }; return { endpointId: id, @@ -857,7 +877,7 @@ export function githubChatWizardService( bot.setup.github?.initialSetupPending ) { try { - await options.refreshRepositories(id, userId); + await options.refreshRepositories(id, userId, managementContext); } catch (error) { return { endpointId: id, @@ -892,7 +912,7 @@ export function githubChatWizardService( .filter((resource) => resource.availability === "available") .map((resource) => ({ id: resource.id, enabled: true })), userId, - { initialGitHubImport: true }, + { initialGitHubImport: true, managementContext }, ); } if (initializeApp && saved.revision === 0 && bot.status !== "active") @@ -911,7 +931,9 @@ export function githubChatWizardService( }, }, userId, + managementContext, ); + await managementContext?.reauthorize(); await setup(bot, { initialRepositoriesImported: true, initialSetupPending: false, @@ -946,7 +968,7 @@ export function githubChatWizardService( bot.status !== "active" && (bot.status !== "verifying" || bot.setup.step === "provider_setup") ) - await options.configure(id, userId); + await options.configure(id, userId, ...(managementContext ? [managementContext] : [])); bot = await endpoint(id, userId); let linked = await linkedIdentity(bot, userId); if (!linked) { @@ -1003,7 +1025,7 @@ export function githubChatWizardService( ); const connectionReady = connectionChecks.every((check) => check.ok); if (connectionReady && bot.status !== "active") - await options.finish(id, userId); + await options.finish(id, userId, ...(managementContext ? [managementContext] : [])); return { endpointId: id, state: connectionReady ? "connected" : "verify", @@ -1102,19 +1124,32 @@ export function githubChatWizardService( ); } } - async function startIdentity(id: string, userId: string) { + async function authorizeBrowserReturn(session: typeof chatGitHubRegistrations.$inferSelect, actor?: AuthorizationActor, identity = false) { + // Internal provider workflows already own their authenticated subject. HTTP + // callbacks always pass their actual browser actor before consuming state. + if (!actor) return; + const sessionId = identity ? session.handoff?.identitySessionId : session.handoff?.browserSessionId; + if (actor.type !== "board" || actor.userId !== session.userId || (sessionId && actor.sessionId !== sessionId)) throw forbidden("Open this saved setup in the configuring user's browser session"); + const bot = await endpoint(session.endpointId, session.userId); + return connectionManagementService(db).authorize(actor, "connections:configure", { + type: "connection_management", companyId: bot.companyId, connectionId: bot.connectionId, + targetAgentId: bot.assignedAgentId, purpose: "chat", provider: "github", operation: "github.authentication.return", + }); + } + async function startIdentity(id: string, userId: string, actor?: AuthorizationActor) { const bot = await endpoint(id, userId), session = await registration(bot); if (!session || session.userId !== userId) throw forbidden("The configuring member must link their GitHub account"); return withIdentityLease(session.id, (assertOwned) => - startIdentityLocked(id, userId, assertOwned), + startIdentityLocked(id, userId, assertOwned, actor), ); } async function startIdentityLocked( id: string, userId: string, assertOwned: () => Promise, + actor?: AuthorizationActor, ) { const bot = await endpoint(id, userId), session = await registration(bot); @@ -1138,7 +1173,7 @@ export function githubChatWizardService( await db .update(chatGitHubRegistrations) .set({ - handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`, + handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ ...(actor?.type === "board" && actor.sessionId ? { identitySessionId: actor.sessionId } : {}), identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`, }) .where(eq(chatGitHubRegistrations.id, session.id)); const challenge = createHash("sha256").update(verifier).digest("base64url"); @@ -1179,6 +1214,7 @@ export function githubChatWizardService( session: typeof chatGitHubRegistrations.$inferSelect, code: string, redirectUri: string, + actor?: AuthorizationActor, ) { return withIdentityLease(session.id, async (assertOwned) => { const [current] = await db @@ -1191,7 +1227,7 @@ export function githubChatWizardService( session.handoff?.identityStateHash ) return resume(await endpoint(session.endpointId, session.userId)); - return completeIdentityLocked(current, code, redirectUri, assertOwned); + return completeIdentityLocked(current, code, redirectUri, assertOwned, actor); }); } async function completeIdentityLocked( @@ -1199,6 +1235,7 @@ export function githubChatWizardService( code: string, redirectUri: string, assertOwned: () => Promise, + actor?: AuthorizationActor, ) { const bot = await endpoint(session.endpointId, session.userId); if ( @@ -1283,6 +1320,7 @@ export function githubChatWizardService( !/^[A-Za-z0-9-]{1,39}$/.test(user.login) ) throw conflict("GitHub returned an invalid account"); + await authorizeBrowserReturn(session, actor, true); // OAuth token remains transient; only an expiring observed identity is saved. await assertOwned(); const identity = { @@ -1303,7 +1341,7 @@ export function githubChatWizardService( }); return resume(bot); } - async function directCallback(state: string, code: string) { + async function directCallback(state: string, code: string, actor?: AuthorizationActor) { const [session] = await db .select() .from(chatGitHubRegistrations) @@ -1315,15 +1353,16 @@ export function githubChatWizardService( session.trustedOrigin !== origin() ) throw forbidden("GitHub return did not match this instance"); + const managementContext = await authorizeBrowserReturn(session, actor); const bot = await endpoint(session.endpointId, session.userId); if (session.status === "failed" || session.status === "exchanging") return resume(bot); if (session.status === "pending") { if (!options.completeDirect) throw conflict("GitHub registration is unavailable"); - await options.completeDirect(state, code); + await options.completeDirect(state, code, managementContext); } - const progress = await advance(bot.id, session.userId); + const progress = await advance(bot.id, session.userId, managementContext); return progress.state === "install" && progress.installationUrl ? progress.installationUrl : resume(bot); @@ -1332,6 +1371,7 @@ export function githubChatWizardService( state: string, registrationId: string, claimId?: string, + actor?: AuthorizationActor, ) { const [session] = await db .select() @@ -1342,6 +1382,7 @@ export function githubChatWizardService( .limit(1); if (!session?.handoff || !claimId) throw forbidden("GitHub return did not match this instance"); + const managementContext = await authorizeBrowserReturn(session, actor, session.handoff.identityStateHash === hash(state)); const cloud = connector(); if (!cloud) throw conflict("Restore Paperclip Cloud enrollment"); const opened = await cloud.claimGitHubApp({ @@ -1422,7 +1463,7 @@ export function githubChatWizardService( }); if (opened.redirectUri !== route.callbackUrls.oauth) throw forbidden("GitHub identity callback did not match"); - return completeIdentity(session, opened.code, route.callbackUrls.oauth); + return completeIdentity(session, opened.code, route.callbackUrls.oauth, actor); } if (manifestReturn && session.status === "pending") { await db @@ -1437,12 +1478,12 @@ export function githubChatWizardService( ), ); } - const progress = await advance(session.endpointId, session.userId); + const progress = await advance(session.endpointId, session.userId, managementContext); return progress.state === "install" && progress.installationUrl ? progress.installationUrl : resume(bot); } - async function identityCallback(state: string, code: string) { + async function identityCallback(state: string, code: string, actor?: AuthorizationActor) { const [session] = await db .select() .from(chatGitHubRegistrations) @@ -1452,6 +1493,7 @@ export function githubChatWizardService( .limit(1); if (!session || session.trustedOrigin !== origin()) throw forbidden("GitHub identity return did not match"); + await authorizeBrowserReturn(session, actor, true); await endpoint(session.endpointId, session.userId); if (!code) { await db @@ -1471,6 +1513,7 @@ export function githubChatWizardService( session, code, `${origin()}/api/chat-github/identity/callback`, + actor, ); } async function confirmIdentity( diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 77dabac142b..1788c0ebd60 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -1,3 +1,5 @@ +import { connectionManagementService } from "./connection-management.js"; +import { CHAT_HUMAN_CREDENTIAL_FIELDS, chatCredentialEntryGenerations } from "./chat-channels/credentials.js"; import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js"; import { emailChannelService } from "./email-channels.js"; import { emailConnectionService } from "./email-connections.js"; @@ -13,6 +15,7 @@ import type { Db } from "@paperclipai/db"; import { agents, aiConnectionTaskPins, + chatEndpoints, companies, toolConnections, connectionGrants, @@ -29,6 +32,7 @@ import { } from "@paperclipai/db"; import { APP_STORE_DEFINITIONS, + isUuidLike, isConnectorFeatureEnabled, getAppDefinitionForApplicationKey, AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES, @@ -61,6 +65,30 @@ import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js"; import { restConnectorFor } from "./connectors/rest-registry.js"; type ConnectionRunClaims = Pick; +type SavedChatSetup = NonNullable; + +/** Opaque completion proof committed alongside the rotated vault references. */ +export function chatCredentialRotationId(setup: object): string | null { + const value: unknown = Reflect.get(setup, "credentialRotationId"); + return typeof value === "string" && isUuidLike(value) ? value : null; +} + +export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string; status?: string; botExternalId?: string | null; providerAccountId?: string | null }, action?: SavedChatSetup["action"], previousRotationId?: string | null, credentialRequest?: { fields?: string[]; requireAll?: boolean }) { + const query = new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id }); + if (action === "enter_credentials" || action === "rotate_credentials") query.set("stage", "credentials"); + const configured = endpoint.status !== "draft" && ( + ["active", "paused", "revoked"].includes(endpoint.status ?? "") || !!endpoint.botExternalId || !!endpoint.providerAccountId + ); + if (action === "enter_credentials" && configured) query.set("reconnect", "1"); + if (action === "confirm_identity") query.set("stage", "identity"); + if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); query.set("afterRotationId", previousRotationId ?? "none"); } + if (action === "enter_credentials") { + const fields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => !credentialRequest?.fields || credentialRequest.fields.includes(field)); + query.set("credentialFields", fields.join(",")); + query.set("credentialMode", credentialRequest?.requireAll ? "all" : "any"); + } + return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`; +} type DbTransaction = Parameters[0]>[0]; @@ -370,8 +398,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 +499,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 +668,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 +676,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 +903,52 @@ export function connectionIntentService(db: Db) { return { ...row, interaction }; } + async function requestChatHandoff(claims: ConnectionRunClaims, endpointId: string, options: { action?: SavedChatSetup["action"]; credentialFields?: 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 credentialFields = CHAT_HUMAN_CREDENTIAL_FIELDS.filter(field => !options.credentialFields || options.credentialFields.includes(field)); + if (options.action === "enter_credentials" && !credentialFields.length) throw unprocessable("Credential entry requires a supported verification field"); + const generations = chatCredentialEntryGenerations(endpoint.setup); + 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, + ...(options.action ? { action: options.action } : {}), + ...(options.action === "enter_credentials" ? { credentialEntry: { previousGenerations: Object.fromEntries(credentialFields.map(field => [field, generations[field]])), requireAll: !!options.credentialFields } } : {}), + ...(options.action === "rotate_credentials" ? { credentialRotation: { previousId: chatCredentialRotationId(endpoint.setup) } } : {}) }, + requestingAgentId: context.agent.id, requestingAgentName: context.agent.name, phase: "authorizing" }, + sourceRunId: context.run.id, sourceIdentityContextId: context.run.activeIdentityContextId, + addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" || options.action === "enter_credentials" ? `:${context.run.id}` : ""}${options.action === "enter_credentials" ? `:${options.credentialFields ? "all" : "any"}:${credentialFields.join(",")}` : ""}`, + }); + if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome."); + return { interactionId: interaction.id, previousRotationId: connectionIntentPayloadSchema.parse(interaction.payload).savedSetup?.credentialRotation?.previousId ?? null }; + } + async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise { const loaded = await loadIntent(interactionId); const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload); + if (payload.savedSetup) { + const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, payload.savedSetup.endpointId), eq(chatEndpoints.companyId, loaded.issue.companyId), eq(chatEndpoints.connectionId, payload.savedSetup.connectionId))); + if (!endpoint || endpoint.assignedAgentId !== payload.requestingAgentId) throw notFound("Saved chat setup not found"); + if (!loaded.interaction.addresseeUserId || endpoint.sponsorUserId !== loaded.interaction.addresseeUserId) throw notFound("Saved chat setup not found"); + await connectionManagementService(db).authorize({ type: "board", userId: loaded.interaction.addresseeUserId, source: "session", ignoreInstanceAdmin: true }, "connections:configure", { + type: "connection_management", companyId: loaded.issue.companyId, connectionId: endpoint.connectionId, + targetAgentId: endpoint.assignedAgentId, purpose: "chat", operation: "chat.authentication_handoff", + }); + const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, loaded.issue.companyId)); + return { version: 1, interaction: loaded.interaction, requestedAgentId: payload.requestingAgentId, existingConnections: [], + service: { service: payload.serviceSlug, name: payload.serviceName, description: null, logoUrl: null, methods: [], state: "needs_user_action", connectionId: endpoint.connectionId }, + chatSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId, + path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action, payload.savedSetup.credentialRotation?.previousId, { + fields: payload.savedSetup.credentialEntry ? Object.keys(payload.savedSetup.credentialEntry.previousGenerations) : undefined, + requireAll: payload.savedSetup.credentialEntry?.requireAll, + }) }, + }; + } 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 +1291,7 @@ export function connectionIntentService(db: Db) { usableConnectionForAgent, search, request, + requestChatHandoff, // Controller-only entry point. Runtime tokens still require a running run. requestForRunAuthFailure: async (runId: string) => { const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); diff --git a/server/src/services/connection-management-policy.ts b/server/src/services/connection-management-policy.ts new file mode 100644 index 00000000000..368878a2851 --- /dev/null +++ b/server/src/services/connection-management-policy.ts @@ -0,0 +1,41 @@ +import type { PermissionKey } from "@paperclipai/shared"; + +/** Domain actions use the ordinary grant evaluator, including its user ceiling. */ +export const CONNECTION_MANAGEMENT_PERMISSIONS = { + "connections:read": "tools:manage_connections", + "connections:configure": "tools:manage_connections", + "connections:profiles": "tools:manage_profiles", + "connections:gateway": "tools:manage_connections", + "connections:audit": "tools:view_audit", + "connections:runtime": "tools:manage_runtime", + "connections:test": "tools:use", +} as const satisfies Record; + +export type ConnectionManagementAction = keyof typeof CONNECTION_MANAGEMENT_PERMISSIONS; + +export function isConnectionManagementAction(action: string): action is ConnectionManagementAction { + return Object.hasOwn(CONNECTION_MANAGEMENT_PERMISSIONS, action); +} + +/** Values come from resolved database rows, never caller-selected identities. */ +export interface ConnectionManagementResource { + type: "connection_management"; + companyId: string; + connectionId?: string | null; + profileId?: string | null; + gatewayId?: string | null; + provider?: string | null; + requestedProvider?: string | null; + purpose?: "tool" | "chat" | "ai"; + operation: string; + readOnly?: boolean; + targetAgentId?: string | null; + projectId?: string | null; + issueId?: string | null; + routineId?: string | null; + /** Personal self-service/creator authority, only for operations that permit it. */ + selfServiceUserId?: string | null; + /** Resolved installation diff changes only agent targets, preserving company sharing. */ + selfServiceAgentInstalls?: boolean; + requestedInstallCompanyIds?: string[]; +} diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts new file mode 100644 index 00000000000..fcc532476be --- /dev/null +++ b/server/src/services/connection-management.ts @@ -0,0 +1,332 @@ +import { and, eq, inArray, or } from "drizzle-orm"; +import { isUuidLike } from "@paperclipai/shared"; +import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolApplications, toolCatalogEntries, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, routines, 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; + workContext: { runId: string | null; issueId: string | null; projectId: string | null }; + decision: AuthorizationDecision; + audit: Pick; + reauthorize: (continuation?: { afterHumanAction: true }) => Promise; +}; + +type CapturedConnectionRun = Omit>, "context"> & { context: Awaited>["context"] | null }; +const connectionReadRuns = new WeakMap(); + +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; readContext?: ConnectionManagementContext; capabilityProbe?: boolean } = {}, + ): Promise { + const responsibleUserId = responsibleConnectionUser(actor); + let workContext: ConnectionManagementContext["workContext"] = { runId: null, issueId: null, projectId: null }; + let capturedRun: CapturedConnectionRun | undefined; + 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 reusable = options.capabilityProbe && !options.afterHumanAction && options.readContext?.actor === actor && options.readContext.resource.companyId === resource.companyId + ? connectionReadRuns.get(options.readContext) : undefined; + const captured = reusable ?? (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 })); + capturedRun = captured; + 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))) : []; + workContext = { runId: run.id, issueId, projectId: issue?.projectId ?? null }; + 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"); + // Managed installation profiles can be empty while their labels still + // identify the connection they administer. + if (profile.profileKey.startsWith("app:") && isUuidLike(profile.profileKey.slice(4))) referencedConnections.push(profile.profileKey.slice(4)); + 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 (["project", "issue", "routine", "gateway"].includes(binding.targetType)) { + await authorizeBindingTarget(actor, action, resource, binding, options); + } + + } + const linkedGateways = await db.select({ id: toolMcpGateways.id }).from(toolMcpGateways).where(and(eq(toolMcpGateways.companyId, resource.companyId), eq(toolMcpGateways.profileId, profile.id))); + for (const gateway of linkedGateways) await authorizeBindingTarget(actor, action, resource, { targetType: "gateway", targetId: gateway.id }, options); + 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); + const applicationIds = new Set(entries.flatMap(entry => entry.selectorType === "application" && entry.applicationId ? [entry.applicationId] : [])); + const catalogIds = new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : [])); + if (catalogIds.size) { + const selected = await db.select({ id: toolCatalogEntries.id, connectionId: toolCatalogEntries.connectionId, applicationId: toolCatalogEntries.applicationId }).from(toolCatalogEntries) + .where(and(eq(toolCatalogEntries.companyId, resource.companyId), inArray(toolCatalogEntries.id, [...catalogIds]))); + if (selected.length !== catalogIds.size) throw forbidden("Profile catalog selection is unavailable in this company"); + for (const entry of selected) { + referencedConnections.push(entry.connectionId); + // New-tool review also covers other connections in the selected + // catalog entry's application, not just its original connection. + if (entry.applicationId) applicationIds.add(entry.applicationId); + } + } + if (applicationIds.size) { + const selected = await db.select({ id: toolApplications.id }).from(toolApplications).where(and(eq(toolApplications.companyId, resource.companyId), inArray(toolApplications.id, [...applicationIds]))); + if (selected.length !== applicationIds.size) throw forbidden("Profile application selection is unavailable in this company"); + const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.companyId, resource.companyId), inArray(toolConnections.applicationId, [...applicationIds]))); + connections.forEach(connection => referencedConnections.push(connection.id)); + } + const toolNames = entries.flatMap(entry => entry.selectorType === "tool_name" && entry.toolName ? [entry.toolName] : []); + const riskLevels = entries.flatMap(entry => entry.selectorType === "risk_level" && entry.riskLevel ? [entry.riskLevel] : []); + if (profile.defaultAction === "allow" || toolNames.length || riskLevels.length) { + const selected = await db.selectDistinct({ connectionId: toolCatalogEntries.connectionId }).from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, resource.companyId), eq(toolCatalogEntries.status, "active"), + profile.defaultAction === "allow" ? undefined : or(toolNames.length ? inArray(toolCatalogEntries.toolName, toolNames) : undefined, riskLevels.length ? inArray(toolCatalogEntries.riskLevel, riskLevels) : undefined), + )); + selected.forEach(entry => 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 + : typeof (connection.config.ai as Record | undefined)?.provider === "string" ? String((connection.config.ai as Record).provider) + : typeof connection.transportConfig.sourceTemplateKey === "string" ? connection.transportConfig.sourceTemplateKey : null, + }; + 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 (resource.routineId) { + const [routine] = await db.select().from(routines).where(and(eq(routines.id, resource.routineId), eq(routines.companyId, resource.companyId))); + if (!routine) throw forbidden("Routine is unavailable in this company"); + resource = { ...resource, projectId: routine.projectId, issueId: routine.parentIssueId }; + if (routine.assigneeAgentId) expandedTargets.add(routine.assigneeAgentId); + else if (options.mutation ?? action !== "connections:read") { + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + rows.forEach(row => expandedTargets.add(row.id)); + } + } + 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 (options.mutation ?? action !== "connections:read") { + if (issue.assigneeAgentId) expandedTargets.add(issue.assigneeAgentId); + else { + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + rows.forEach(row => expandedTargets.add(row.id)); + } + } + if (issue.projectId && issue.projectId !== resource.projectId) { + const [project] = await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, resource.companyId))); + if (!project || !(await canActorReadProjectPrivacy(db, actor, project))) throw forbidden("Task project is unavailable to this delegated principal"); + } + resource = { ...resource, projectId: resource.projectId ?? issue.projectId }; + 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 (Object.hasOwn(resource, "requestedProvider") && resource.requestedProvider !== resource.provider) { + const proposed = { ...resolved, provider: resource.requestedProvider }; + const proposedDecision = await authorization.decide({ actor, action, resource: proposed, scope: { ...proposed } }); + if (!proposedDecision.allowed) throw forbidden(proposedDecision.explanation, { reason: proposedDecision.reason, code: proposedDecision.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 }); + } + const context: ConnectionManagementContext = { + actor, responsibleUserId, resource, workContext, 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) => { + const current = await authorize(actor, action, resource, { ...options, afterHumanAction: continuation?.afterHumanAction ?? options.afterHumanAction }); + context.workContext = current.workContext; + const snapshot = connectionReadRuns.get(current); + if (snapshot) connectionReadRuns.set(context, snapshot); + }, + }; + if (capturedRun) connectionReadRuns.set(context, capturedRun); + return context; + } + + async function authorizeBindingTarget(actor: AuthorizationActor, action: ConnectionManagementAction, + resource: ConnectionManagementResource, binding: { targetType: string; targetId: string }, + options: NonNullable[3]> = {}): Promise { + let resolved = { ...resource }; + const checkOptions = { ...options, expandedProfile: true }; + if (binding.targetType === "project") { + resolved.projectId = binding.targetId; + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + checkOptions.targetAgentIds = [...new Set([...(options.targetAgentIds ?? []), ...rows.map(row => row.id)])]; + } + else if (binding.targetType === "issue") resolved.issueId = binding.targetId; + else if (binding.targetType === "routine") resolved.routineId = binding.targetId; + else if (binding.targetType === "gateway") { + const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, binding.targetId), eq(toolMcpGateways.companyId, resource.companyId))); + if (!gateway) throw forbidden("Binding gateway is unavailable in this company"); + resolved = { ...resolved, gatewayId: gateway.id, projectId: gateway.projectId, issueId: gateway.issueId }; + checkOptions.gatewayContextChecked = true; + const targets = new Set(options.targetAgentIds ?? []); + if (gateway.agentId) targets.add(gateway.agentId); + else { + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + rows.forEach(row => targets.add(row.id)); + } + checkOptions.targetAgentIds = [...targets]; + } else throw forbidden("Unsupported profile binding target"); + return authorize(actor, action, resolved, checkOptions); + } + + async function capability(...args: Parameters) { + try { + const context = await authorize(args[0], args[1], args[2], { ...args[3], capabilityProbe: true }); + 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, authorizeBindingTarget, 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 { + if (!context) return undefined; + const continuation: ConnectionManagementContext = { ...context, reauthorize: async () => { + await context.reauthorize({ afterHumanAction: true }); + continuation.workContext = context.workContext; + continuation.resource = context.resource; + continuation.decision = context.decision; + continuation.audit = context.audit; + } }; + return continuation; +} diff --git a/server/src/services/connectors/slack/setup/registration.ts b/server/src/services/connectors/slack/setup/registration.ts index ca44f0e7f50..9b6d87ec941 100644 --- a/server/src/services/connectors/slack/setup/registration.ts +++ b/server/src/services/connectors/slack/setup/registration.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "../../../connection-management.js"; import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; import { createAgentAvatarPool } from "../../../agent-avatar-pool.js"; import type { AgentAvatarRequest } from "../../../agent-avatars.js"; @@ -12,7 +13,7 @@ import { secretService } from "../../../secrets.js"; import { writeConnectionCredential } from "../../../connection-credentials.js"; import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; -export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } +export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean; managementContext?: ConnectionManagementContext } export type Registration = typeof chatSlackRegistrations.$inferSelect; @@ -59,6 +60,7 @@ export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; } async function endpoint(endpointId: string, actor: SlackSetupActor) { + await actor.managementContext?.reauthorize(); if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) @@ -76,13 +78,14 @@ export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); } async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { - await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, + await logActivity(db, { companyId: row.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }), action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); } const api = createSlackSetupClient(fetchImpl); async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, patch: Partial = {}, account?: SlackAccountState) { + await actor.managementContext?.reauthorize(); await db.transaction(async tx => { await lease.assertOwned(tx); const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts index 47996f0acf5..86d6a65f6ad 100644 --- a/server/src/services/connectors/slack/test-message.ts +++ b/server/src/services/connectors/slack/test-message.ts @@ -1,3 +1,4 @@ +import { toolAccessPolicyService } from "../../tool-access-policy.js"; import { and, eq, gte, inArray, ne } from "drizzle-orm"; import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db"; import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared"; @@ -19,6 +20,7 @@ const human = (user: Record, workspaceId: string) => export function slackTestMessageService(db: Db, options: Options) { const api = createSlackSetupClient(options.fetch); return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise => { + await actor.managementContext?.reauthorize(); const input = slackTestMessageSchema.parse(rawInput); if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); return options.withLock(endpointId, async lease => { @@ -34,6 +36,35 @@ 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.workContext.runId, issueId: context.workContext.issueId, + projectId: context.workContext.projectId, originatingAgentId: context.actor.agentId! }, + consumeRateLimit: true, + 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 callerRequest = context.actor.agentId !== endpoint.assignedAgentId + ? { ...request, actor: { ...request.actor, agentId: context.actor.agentId! } } : null; + const callerDecision = callerRequest ? await policy.decide(callerRequest) : null; + const targetDecision = await policy.decide(request, { sharedRateDecision: callerDecision ?? undefined }); + // Either principal's denial or Ask first decision prevents dispatch. + // The evaluator shares an admission only for the identical rate bucket. + const decision = callerDecision && !callerDecision.allowed && callerDecision.decision !== "require_approval" ? callerDecision + : !targetDecision.allowed ? targetDecision + : callerDecision?.decision === "require_approval" ? callerDecision : targetDecision; + if (callerRequest && callerDecision) await policy.writeAudit(callerRequest, callerDecision); + await policy.writeAudit(request, targetDecision); + 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 +117,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 +136,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 +159,7 @@ export function slackTestMessageService(db: Db, options: Options) { await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message."); } - try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + try { await logActivity(db, { companyId: endpoint.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }), action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); } return { status: "sent", slackUrl }; }); diff --git a/server/src/services/device-login-service.ts b/server/src/services/device-login-service.ts index b409bef2c4f..c5ee52a96ff 100644 --- a/server/src/services/device-login-service.ts +++ b/server/src/services/device-login-service.ts @@ -1,5 +1,6 @@ import { writeLoginCleanup } from "./login-cleanup-notifications.js"; import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; +import type { ConnectionManagementContext } from "./connection-management.js"; import { randomUUID } from "node:crypto"; import { and, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -148,6 +149,7 @@ export interface LoginSessionRuntime { * session, the company, the owner, and the adapter, so the promotion resolves * the company slot and the sole-active-owner check for this exact session. */ export interface CredentialPromotionContext { + managementContext?: ConnectionManagementContext; sessionId: string; companyId: string; startedByUserId: string; @@ -210,6 +212,7 @@ export interface LoginSessionActivityEvent { export type LoginSessionActivityRecorder = (event: LoginSessionActivityEvent) => void; export interface StartDeviceLoginInput { + managementContext?: ConnectionManagementContext; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent; companyId: string; environmentId: string; @@ -915,6 +918,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) { async function start( input: StartDeviceLoginInput, ): Promise { + await input.managementContext?.reauthorize(); const sessionId = randomUUID(); // The public session identifier the API returns and looks up. It is an // independent CSPRNG value, so it never equals the internal `sessionId` and a @@ -1124,7 +1128,9 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) { if (!credential || credential.length === 0) { throw new Error("missing_credential"); } + await input.managementContext?.reauthorize(); await profile.promotion.promote(credential, { + managementContext: input.managementContext, sessionId, companyId: input.companyId, startedByUserId: input.startedByUserId, @@ -1159,12 +1165,14 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) { resultClaim: resultClaim ?? null, }); const promotionContext: CredentialPromotionContext = { + managementContext: input.managementContext, sessionId, companyId: input.companyId, startedByUserId: input.startedByUserId, adapterType: input.adapterType, }; try { + await input.managementContext?.reauthorize(); return profile.promotion.runTerminalCommit ? await profile.promotion.runTerminalCommit(commitAuthenticated, promotionContext) : await commitAuthenticated(); diff --git a/server/src/services/email-channels.ts b/server/src/services/email-channels.ts index 688aa2d6cda..782a53f10d1 100644 --- a/server/src/services/email-channels.ts +++ b/server/src/services/email-channels.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "./connection-management.js"; import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { fastResponseService, enqueueFastResponse, fastResponseSourceCurrent, fastResponseTurnQueued } from "./fast-responses.js"; @@ -72,6 +73,7 @@ import { } from "./agentmail-api.js"; export type EmailActor = { + managementContext?: ConnectionManagementContext; userId?: string; agentId?: string; runId?: string; @@ -277,6 +279,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { companyId: endpoint.companyId, actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system", actorId: actor.agentId ?? actor.userId ?? "agentmail", + ...actor.managementContext?.audit, action, entityType: "tool_connection", entityId: endpoint.connectionId, @@ -627,6 +630,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { input: EmailEndpointSetupInput, actor: EmailActor, ) { + await actor.managementContext?.reauthorize(); const [agent] = await db .select() .from(agents) @@ -684,6 +688,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { ) throw conflict("Setup request already belongs to another inbox"); if (!endpoint) { + await actor.managementContext?.reauthorize(); const applicationId = input.applicationId ?? randomUUID(); const connectionId = randomUUID(); await db.transaction(async (tx) => { @@ -725,6 +730,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { authKind: "api_key", ownership: "customer", credentialPolicy: "shared", + createdByUserId: actor.userId ?? null, status: "draft", enabled: false, config: { provider: "agentmail" }, @@ -831,6 +837,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { }); if (scope.scope_type === "inbox" && scope.inbox_id !== inbox.inbox_id) throw forbidden("API key belongs to a different inbox"); + await actor.managementContext?.reauthorize(); await db .update(chatEndpoints) .set({ @@ -851,6 +858,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { if (!config.ownedApiKeyId && scope.scope_type !== "inbox") { const key = await api.createInboxKey(inbox.inbox_id); try { + await actor.managementContext?.reauthorize(); await vault(endpoint, "apiKey", key.api_key); await db .update(emailEndpoints) @@ -881,6 +889,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { ); const webhook = await createWebhook({ ...endpoint, botExternalId: inbox.inbox_id }, api); try { + await actor.managementContext?.reauthorize(); await vault(endpoint, "webhookSecret", webhook.secret); await db .update(emailEndpoints) @@ -891,6 +900,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { throw error; } } + await actor.managementContext?.reauthorize(); const now = new Date(); await db.transaction(async (tx) => { await notifyDeliveryWork(tx, DELIVERY_QUEUES.email); @@ -2375,6 +2385,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { action: "pause" | "resume" | "remove", actor: EmailActor, ) { + await actor.managementContext?.reauthorize(); const endpoint = await getEndpoint(id); const result = await withLease(endpoint, async () => { const config = await getConfig(id); @@ -2387,6 +2398,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { endpoint.botExternalId!, ); } + await actor.managementContext?.reauthorize(); if (action !== "resume") await stopEndpoint(endpoint); let cleanupError: string | null = null; if (action === "remove") { @@ -2477,6 +2489,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { receiveMode: "websocket" | "webhook", actor: EmailActor, ) { + await actor.managementContext?.reauthorize(); const endpoint = await getEndpoint(id); if (endpoint.status === "archived" || !endpoint.botExternalId) throw conflict("Create a new inbox connection"); @@ -2488,6 +2501,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { ) throw forbidden("API key belongs to a different inbox"); await api.getInbox(endpoint.botExternalId); + await actor.managementContext?.reauthorize(); const result = await withLease(endpoint, async () => { const config = await getConfig(id); // Check registration permissions before interrupting a working socket. @@ -2496,6 +2510,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { : null; if (preparedWebhook) { try { + await actor.managementContext?.reauthorize(); await vault(endpoint, "webhookSecret", preparedWebhook.secret); await db.update(emailEndpoints).set({ webhookId: preparedWebhook.webhook_id }) .where(eq(emailEndpoints.endpointId, id)); @@ -2504,6 +2519,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { throw error; } } + await actor.managementContext?.reauthorize(); await stopEndpoint(endpoint); // Only registrations and scoped keys created by Paperclip are removed. if (config.webhookId) diff --git a/server/src/services/email-connections.ts b/server/src/services/email-connections.ts index 78f38ad5058..2e9676d618a 100644 --- a/server/src/services/email-connections.ts +++ b/server/src/services/email-connections.ts @@ -15,6 +15,7 @@ import { lockToolConnectionRuntimeInputs, toolAccessService } from "./tool-acces import { agentmailApi } from "./agentmail-api.js"; import { logActivity } from "./activity-log.js"; import type { EmailActor } from "./email-channels.js"; +import { connectionManagementService } from "./connection-management.js"; export function emailConnectionService( db: Db, @@ -76,6 +77,8 @@ export function emailConnectionService( return connection; } async function listCredentials(companyId: string, actor: EmailActor): Promise { + const management = actor.managementContext; + if (actor.agentId && !management) throw forbidden("Connection management authority is required"); const connections = await db.select().from(toolConnections).where(and( eq(toolConnections.companyId, companyId), eq(toolConnections.status, "active"), eq(toolConnections.enabled, true), @@ -92,6 +95,11 @@ export function emailConnectionService( await Promise.all(Array.from({ length: Math.min(4, candidates.length) }, async () => { while (next < candidates.length) { const connection = candidates[next++]; + if (actor.agentId && management) { + const access = await connectionManagementService(db).capability(management.actor, "connections:read", + { ...management.resource, connectionId: connection.id }, { mutation: false, readContext: management }); + if (!access.allowed) continue; + } try { await get(companyId, connection.id, actor); } catch (error) { @@ -176,7 +184,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 +262,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 +297,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 +310,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 +370,17 @@ export function emailConnectionService( ], }, { - actorType: "user", - actorId: actor.userId ?? "board", + actorType: actor.agentId ? "agent" : "user", + actorId: actor.agentId ?? actor.userId ?? "board", + managementContext: actor.managementContext, actorSource: actor.localImplicit ? "local_implicit" : "session", }, ); await logActivity(db, { companyId, - actorType: "user", - actorId: actor.userId ?? "board", + actorType: actor.agentId ? "agent" : "user", + actorId: actor.agentId ?? actor.userId ?? "board", + ...actor.managementContext?.audit, action: replaceInitialAccess ? "email.connection.agents_updated" : "email.connection.agent_added", entityType: "tool_connection", entityId: id, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 9a4ed571a5b..bcfbe2882a9 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2613,6 +2613,16 @@ export function issueThreadInteractionService( }, ) => { const payload = connectionIntentPayloadSchema.parse(input.payload); + const sameSavedSetup = (candidate: typeof payload) => + candidate.savedSetup?.endpointId === payload.savedSetup?.endpointId + && candidate.savedSetup?.connectionId === payload.savedSetup?.connectionId + && candidate.savedSetup?.action === payload.savedSetup?.action + && Boolean(candidate.savedSetup?.credentialRotation) === Boolean(payload.savedSetup?.credentialRotation) + && candidate.savedSetup?.credentialEntry?.requireAll === payload.savedSetup?.credentialEntry?.requireAll + // Preserve the original generation boundary on retries, while keeping + // requests for different credential fields distinct. + && isDeepStrictEqual(Object.keys(candidate.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort(), + Object.keys(payload.savedSetup?.credentialEntry?.previousGenerations ?? {}).sort()); const existing = await getIdempotentInteraction({ issueId: issue.id, companyId: issue.companyId, @@ -2624,7 +2634,7 @@ export function issueThreadInteractionService( || existing.sourceRunId !== input.sourceRunId || existing.addresseeUserId !== input.addresseeUserId || (existing.kind === "connection_intent" - ? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest)) + ? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !sameSavedSetup(connectionIntentPayloadSchema.parse(existing.payload)) || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest)) : !isDeepStrictEqual(existing.payload, payload)) ) { throw conflict( @@ -2663,7 +2673,7 @@ export function issueThreadInteractionService( eq(issueThreadInteractions.addresseeUserId, input.addresseeUserId), )); const reusable = pending.find((candidate) => - connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest)); + connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && sameSavedSetup(connectionIntentPayloadSchema.parse(candidate.payload)) && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest)); if (reusable) { await enqueueIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(reusable)); return reusable; @@ -2689,8 +2699,11 @@ export function issueThreadInteractionService( sourceRunId: input.sourceRunId, originCommentIds, sourceIdentityContextId: input.sourceIdentityContextId ?? null, - title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?` : `Connect ${payload.serviceName}`, - summary: `${payload.requestingAgentName} needs this connection to continue.`, + title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?` + : payload.savedSetup?.action === "rotate_credentials" ? `Rotate ${payload.serviceName} webhook secret` : `Connect ${payload.serviceName}`, + summary: payload.savedSetup?.action === "rotate_credentials" + ? `${payload.requestingAgentName} needs you to rotate the signing secret and reconnect the saved App.` + : `${payload.requestingAgentName} needs this connection to continue.`, createdByAgentId: payload.requestingAgentId, addresseeUserId: input.addresseeUserId, payload, diff --git a/server/src/services/local-ai-login.ts b/server/src/services/local-ai-login.ts index 3da1fa60ec7..6de405ef449 100644 --- a/server/src/services/local-ai-login.ts +++ b/server/src/services/local-ai-login.ts @@ -1,4 +1,5 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; +import type { ConnectionManagementContext } from "./connection-management.js"; import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; @@ -83,10 +84,11 @@ export function localAiLoginService(db: Db) { } } - async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false): Promise { + async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false, managementContext?: ConnectionManagementContext): Promise { if (intent.provider !== "openai" && intent.provider !== "xai" && intent.provider !== "anthropic") throw unprocessable("This provider does not use a separate local login home."); await reapExpired(); + await managementContext?.reauthorize(); const attempt = await db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-local-login:${companyId}:${userId}:${intent.provider}`}, 0))`); const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local"; @@ -109,7 +111,7 @@ export function localAiLoginService(db: Db) { await tx.update(adapterAuthSessions).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() }) .where(eq(adapterAuthSessions.id, existing.id)); await logActivity(tx as unknown as Db, { - companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled", + companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_cancelled", entityType: "adapter_auth_session", entityId: existing.id, details: { provider: intent.provider }, }); } @@ -129,7 +131,7 @@ export function localAiLoginService(db: Db) { connectionMethod: LOCAL_LOGIN_METHOD, status: "waiting_for_user", expiresAt, }); await logActivity(tx as unknown as Db, { - companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_started", + companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_started", entityType: "adapter_auth_session", entityId: id, details: { provider: intent.provider }, }); } catch (error) { @@ -201,7 +203,8 @@ export function localAiLoginService(db: Db) { }); } - async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent) { + async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); const result = await db.transaction(async (tx) => { const [session] = await tx.select().from(adapterAuthSessions).where(and( eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId), @@ -222,7 +225,7 @@ export function localAiLoginService(db: Db) { // Nested transaction is a savepoint on this same connection. Holding the // attempt lock makes completion/cancellation/restart retries idempotent. const saved = await aiConnectionService(tx as unknown as Db) - .save(companyId, userId, intent, credential, { id }, session.createdAt); + .save(companyId, userId, intent, credential, { id }, session.createdAt, managementContext); await tx.update(adapterAuthSessions).set({ status: "authenticated", connectionMethod: LOCAL_LOGIN_METHOD, finishedAt: new Date(), updatedAt: new Date(), @@ -235,7 +238,8 @@ export function localAiLoginService(db: Db) { return result; } - async function cancel(companyId: string, userId: string, id: string) { + async function cancel(companyId: string, userId: string, id: string, managementContext?: ConnectionManagementContext) { + await managementContext?.reauthorize(); await db.transaction(async (tx) => { const [session] = await tx.select().from(adapterAuthSessions).where(and( eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId), @@ -250,7 +254,7 @@ export function localAiLoginService(db: Db) { status: "cancelled", finishedAt: new Date(), updatedAt: new Date(), }).where(eq(adapterAuthSessions.id, id)); await logActivity(tx as unknown as Db, { - companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled", + companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_cancelled", entityType: "adapter_auth_session", entityId: id, details: { provider: session.aiConnection?.provider }, }); diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index 5033a29dd9c..7cf47fc34df 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -89,9 +89,9 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): && /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path); const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path); const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`]; - const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path) + const protocol = operation["x-paperclip-transport"] === "protocol" || (operation["x-paperclip-transport"] !== "rest" && (!path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path) || /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path) - || /event-stream|websocket/i.test(JSON.stringify(operation.responses)); + || /event-stream|websocket/i.test(JSON.stringify(operation.responses)))); result.push({ operationId: `${method} ${path}`, method, path, summary: operation.summary ?? `${method} ${path}`, @@ -114,7 +114,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent") ? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions." : "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.", - allowedModes: method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"] + allowedModes: operation["x-paperclip-connection-management"] ? (method === "GET" ? ["standard", "ask", "planning"] : ["standard"]) : method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"] : method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"] : conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"], ...(skillReference ? { skillReference } : {}), diff --git a/server/src/services/native-runtime/runner-api-sensitive-response.ts b/server/src/services/native-runtime/runner-api-sensitive-response.ts index 1920d27e239..97410b665cf 100644 --- a/server/src/services/native-runtime/runner-api-sensitive-response.ts +++ b/server/src/services/native-runtime/runner-api-sensitive-response.ts @@ -1,4 +1,6 @@ export function runnerApiSensitiveResponse(method: string, path: string): boolean { + if (method === "POST" && /^\/api\/chat-endpoints\/\{[^}]+\}\/setup$/.test(path)) return true; + 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|check)(\/|$)|\/test-calls(\/|$))/.test(path)) return true; if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true; return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path) || /^\/api\/companies\/\{[^}]+\}\/exports?(?:\/|$)/.test(path) diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index 59eec13aff6..f5ad26f3e51 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -11,6 +11,7 @@ import { validateRunnerApiCall, type RunnerApiIo, } from "./runner-api-client.js"; +import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { RUNNER_API_RESPONSE_MAX_BYTES } from "./runner-api-response-limits.js"; const context = { @@ -57,6 +58,26 @@ describe("bounded response capture receipts", () => { }); }); +describe("connection setup response protection", () => { + it.each([ + ["POST", "/api/chat-endpoints/{endpointId}/setup"], + ["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"], + ["POST", "/api/companies/{companyId}/ai-connections/local/check"], + ["POST", "/api/tool-connections/{connectionId}/test-calls"], + ["GET", "/api/tool-connections/{connectionId}/test-calls/{actionRequestId}"], + ])("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 +218,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 +229,16 @@ describe("runner API request boundary", () => { expect(request).not.toHaveBeenCalled(); }, ); + it.each([ + "POST /api/companies/{companyId}/adapters/{type}/login-sessions", + "POST /api/agents/me/connections/{connectionId}/start-authorization", + ])("calls JSON authentication initiation through REST: %s", async operationId => { + const request = vi.fn(async () => Response.json({ error: "Route permission required" }, { status: 403 })); + expect(runnerApiOperation(operationId).transport).toBe("rest"); + const result = await executeRunnerApi({ operationId, pathParams: operationId.includes("{type}") ? { type: "codex_local" } : { connectionId: "connection-a" }, body: {} }, context, io(request)); + expect(result).toMatchObject({ status: 403 }); + expect(request).toHaveBeenCalledOnce(); + }); it.each( runnerApiCatalog().filter( (operation) => diff --git a/server/src/services/satisfied-connection-intents.ts b/server/src/services/satisfied-connection-intents.ts index 61b7bb21783..74503f0d098 100644 --- a/server/src/services/satisfied-connection-intents.ts +++ b/server/src/services/satisfied-connection-intents.ts @@ -1,9 +1,11 @@ import { enqueueTerminalIssueInteractionChatPublications } from "./chat-interaction-publications.js"; +import { connectionManagementService } from "./connection-management.js"; +import { chatCredentialEntryGenerations } from "./chat-channels/credentials.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, chatExternalPrincipals, chatIdentityLinks, 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"; +import { chatCredentialRotationId, connectionIntentService } from "./connection-intents.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; import { logger } from "../middleware/logger.js"; import { createToolGatewayService } from "./tool-gateway.js"; @@ -19,6 +21,48 @@ 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; + if (payload.savedSetup.action === "confirm_identity") { + if (!endpoint.providerAccountId) return null; + const [link] = await db.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks) + .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), + eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId))) + .where(and(eq(chatIdentityLinks.companyId, task.companyId), eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.paperclipUserId, intent.addresseeUserId), eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, endpoint.provider), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId))) + .limit(1); + if (!link) return null; + } + if (payload.savedSetup.action === "enter_credentials") { + const proof = payload.savedSetup.credentialEntry; + if (!proof) return null; + const generations = chatCredentialEntryGenerations(endpoint.setup); + const fields = Object.keys(proof.previousGenerations); + const changed = fields.filter(field => typeof generations[field] === "string" && generations[field] !== proof.previousGenerations[field]); + if (!changed.length || (proof.requireAll && changed.length !== fields.length)) return null; + if (changed.some(field => field === "signingSecret" || field === "webhookSecret") && !endpoint.setup.webhookVerifiedAt) return null; + } + if (payload.savedSetup.credentialRotation) { + // Rotation commits its generation and clears webhook verification in the + // same transaction as vault refs. Old health cannot satisfy this request. + const generation = chatCredentialRotationId(endpoint.setup); + if (!generation || generation === payload.savedSetup.credentialRotation.previousId || !endpoint.setup.webhookVerifiedAt) return null; + } + const capability = await connectionManagementService(db).capability({ type: "agent", agentId: payload.requestingAgentId, + companyId: task.companyId, onBehalfOfUserId: intent.addresseeUserId }, "connections:configure", { + type: "connection_management", companyId: task.companyId, connectionId: connection.id, projectId: task.projectId, + issueId: task.id, targetAgentId: payload.requestingAgentId, purpose: "chat", operation: "chat.authentication_handoff", + }); + return capability.allowed ? connection : null; + } // Additional permissions, provider authentication and inbox setup still need // their governed completion paths. Aggregator cards also name an upstream app. if (payload.purpose || payload.accessRequest || payload.upstreamService diff --git a/server/src/services/setup-token-ai-connection.ts b/server/src/services/setup-token-ai-connection.ts index 4b3547816fa..4d7df562d3a 100644 --- a/server/src/services/setup-token-ai-connection.ts +++ b/server/src/services/setup-token-ai-connection.ts @@ -30,5 +30,5 @@ export async function completeSetupTokenAiConnection( }); if (!transitioned) throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED); }, - }); + }, undefined, scope.managementContext); } diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts index bd291a7b8cf..a1c1ec9d072 100644 --- a/server/src/services/setup-token-session.ts +++ b/server/src/services/setup-token-session.ts @@ -1,5 +1,6 @@ import { writeLoginCleanup } from "./login-cleanup-notifications.js"; import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; +import type { ConnectionManagementContext } from "./connection-management.js"; // The Claude setup-token login session service. It owns a company-scoped, // owner-bound login session that holds one live setup-token process and one // sandbox lease for the whole login. The login is a two-way round-trip: the @@ -111,6 +112,7 @@ export const SETUP_TOKEN_CANCELLABLE_STATES: readonly SetupTokenSessionState[] = * per environment. */ export interface SetupTokenSessionScope { + managementContext?: ConnectionManagementContext; aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent; companyId: string; ownerUserId: string; diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index 8ebda1efeb6..16ac7f6fdce 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -1,3 +1,4 @@ +import type { ConnectionManagementContext } from "./connection-management.js"; import { createHash } from "node:crypto"; import { boundRunIssueIdSql } from "./run-task-binding.js"; import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm"; @@ -17,6 +18,7 @@ import { toolCallEvents, toolInvocations, toolMcpGateways, + toolMcpGatewayTokens, toolPolicies, toolProfileBindings, toolProfileEntries, @@ -189,7 +191,7 @@ function sha256(value: unknown): string { return createHash("sha256").update(stableStringify(value)).digest("hex"); } -type ConsumedRateAdmission = { policyId: string; versionHash: string }; +type ConsumedRateAdmission = { policyId: string; versionHash: string; bucketKey?: string }; function ratePolicyVersionHash(policy: typeof toolPolicies.$inferSelect): string { return sha256({ ...policy, createdAt: policy.createdAt.toISOString(), updatedAt: policy.updatedAt.toISOString() }); } @@ -784,6 +786,8 @@ function scopeAllowsTool(scope: Record | null, ctx: ToolAccessC } export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecret?: string } = {}) { + // Policy administration includes authorized self-targeted changes. It cannot + // resolve governed action requests or modify principal permission grants. async function listPolicies(companyId: string) { return db .select() @@ -792,12 +796,13 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr .orderBy(asc(toolPolicies.priority), desc(toolPolicies.updatedAt)); } - async function reorderPolicies(companyId: string, body: ReorderToolPolicies) { + async function reorderPolicies(companyId: string, body: ReorderToolPolicies, managementContext?: ConnectionManagementContext) { const uniquePolicyIds = [...new Set(body.policyIds)]; if (uniquePolicyIds.length !== body.policyIds.length) { throw badRequest("policyIds must not contain duplicates"); } + await managementContext?.reauthorize(); return db.transaction(async (tx) => { const rows = await tx .select() @@ -839,6 +844,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr policyId: string; body: DuplicateToolPolicy; actor?: { agentId?: string | null; userId?: string | null }; + managementContext?: ConnectionManagementContext; }) { const existing = await getGenericPolicyRow(db, input.companyId, input.policyId); assertSupportedGenericPolicyShape(existing.policyType, existing.conditions ?? null, existing.config ?? null); @@ -854,6 +860,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr while (names.has(`${baseName} ${suffix}`)) suffix += 1; name = `${baseName} ${suffix}`; } + await input.managementContext?.reauthorize(); const now = new Date(); const [policy] = await db .insert(toolPolicies) @@ -880,8 +887,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr companyId: string, body: CreateToolPolicy, actor?: { agentId?: string | null; userId?: string | null }, + managementContext?: ConnectionManagementContext, ) { assertSupportedGenericPolicyShape(body.policyType, body.conditions ?? null, body.config ?? null); + await managementContext?.reauthorize(); const now = new Date(); const [policy] = await db .insert(toolPolicies) @@ -908,12 +917,14 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr companyId: string; policyId: string; body: UpdateToolPolicy; + managementContext?: ConnectionManagementContext; }) { const existing = await getGenericPolicyRow(db, input.companyId, input.policyId); const nextPolicyType = input.body.policyType ?? existing.policyType; const nextConditions = input.body.conditions !== undefined ? input.body.conditions ?? null : existing.conditions ?? null; const nextConfig = input.body.config !== undefined ? input.body.config ?? null : existing.config ?? null; assertSupportedGenericPolicyShape(nextPolicyType, nextConditions, nextConfig); + await input.managementContext?.reauthorize(); const now = new Date(); const [policy] = await db .update(toolPolicies) @@ -933,8 +944,9 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr return policy; } - async function deletePolicy(input: { companyId: string; policyId: string }) { + async function deletePolicy(input: { companyId: string; policyId: string; managementContext?: ConnectionManagementContext }) { const existing = await getGenericPolicyRow(db, input.companyId, input.policyId); + await input.managementContext?.reauthorize(); const [deleted] = await db .delete(toolPolicies) .where(eq(toolPolicies.id, existing.id)) @@ -992,6 +1004,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr agentId = agent.id; } + const originatingAgentId = input.runContext?.originatingAgentId; + if (originatingAgentId && (input.actor.actorType !== "agent" || input.actor.actorId !== originatingAgentId)) { + return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Run origin does not match the authenticated actor.", [], [], { redactionPlan: redaction.redactionPlan }) }; + } if (heartbeatRunId) { const runId = heartbeatRunId; const [run] = await cachedRead(cache, `run:${runId}`, async () => @@ -1005,10 +1021,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId))); - if (!run || run.companyId !== input.companyId || (input.actor.actorType === "agent" && run.agentId !== agentId)) { + if (!run || run.companyId !== input.companyId || (input.actor.actorType === "agent" && run.agentId !== (originatingAgentId ?? agentId))) { return { ok: false, redaction, decision: decision("deny", "deny_run_context_mismatch", "Supplied run context does not match the authenticated actor.", [], [], { redactionPlan: redaction.redactionPlan }) }; } - agentId = run.agentId; + agentId = originatingAgentId ? agentId : run.agentId; const runIssueId = nonBlankString(run.issueId); const runProjectId = nonBlankString(run.projectId); const runRoutineId = nonBlankString(run.routineId); @@ -1371,16 +1387,22 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr status: toolInvocations.status, policyDecision: toolInvocations.policyDecision, approvalState: toolInvocations.approvalState, policyExplanation: toolInvocations.policyExplanation, }).from(toolInvocations).where(and(eq(toolInvocations.companyId, ctx.companyId), eq(toolInvocations.id, invocationId))); - const authorized = invocation && invocation.status === "executing" - && (invocation.policyDecision === "allow" || (invocation.policyDecision === "require_approval" && invocation.approvalState === "approved")) - && invocation.actorType === ctx.actorType && invocation.actorId === ctx.actorId && invocation.agentId === ctx.agentId + const delegated = invocation?.policyExplanation?.delegatedConnectionTest as { agentId?: string } | undefined; + const gatewayIssuer = invocation?.gatewayId ? invocation.policyExplanation?.delegatedGatewayIssuer : undefined; + const isDelegatedCaller = invocation?.actorType === "agent" && invocation.actorId === ctx.agentId + && (delegated?.agentId === ctx.agentId || gatewayIssuer === ctx.agentId) && invocation.agentId !== ctx.agentId; + const authorized = invocation && ( + (invocation.status === "executing" && (invocation.policyDecision === "allow" || (invocation.policyDecision === "require_approval" && invocation.approvalState === "approved"))) + || (invocation.status === "awaiting_approval" && invocation.policyDecision === "require_approval" && ["pending", "approved"].includes(invocation.approvalState)) + ) + && invocation.actorType === ctx.actorType && invocation.actorId === ctx.actorId && (invocation.agentId === ctx.agentId || isDelegatedCaller) && invocation.runId === ctx.heartbeatRunId && invocation.issueId === ctx.issueId && invocation.gatewayId === ctx.gatewayId && invocation.applicationId === ctx.applicationId && invocation.connectionId === ctx.connectionId && invocation.catalogEntryId === ctx.catalogEntryId && invocation.toolName === ctx.toolName && invocation.providerType === ctx.providerType && invocation.applicationKey === ctx.applicationKey && invocation.upstreamToolName === ctx.upstreamToolName && invocation.argumentsHash === ctx.argumentsHash; - const stored = authorized ? invocation.policyExplanation?.consumedRateAdmissions : null; + const stored = authorized ? invocation.policyExplanation?.[isDelegatedCaller ? "delegatedCallerConsumedRateAdmissions" : "consumedRateAdmissions"] : null; return Array.isArray(stored) ? stored.filter((item): item is ConsumedRateAdmission => isRecord(item) && typeof item.policyId === "string" && CANONICAL_UUID_RE.test(item.policyId) && typeof item.versionHash === "string" && /^[a-f0-9]{64}$/.test(item.versionHash)) : []; @@ -1393,7 +1415,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr */ async function decide( input: ToolAccessDecisionInput, - options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string } = {}, + options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string; sharedRateDecision?: ToolAccessDecision } = {}, ): Promise { const consumedRateAdmissions: ConsumedRateAdmission[] = []; const result = await decideInternal(input, options, consumedRateAdmissions); @@ -1404,7 +1426,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr async function decideInternal( input: ToolAccessDecisionInput, - options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string }, + options: { cache?: ToolAccessDecisionCache; admittedInvocationId?: string; sharedRateDecision?: ToolAccessDecision }, consumedRateAdmissions: ConsumedRateAdmission[], ): Promise { // A decision that consumes a rate limit can also record a trust-rule hit @@ -1492,11 +1514,20 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr } const versionHash = ratePolicyVersionHash(policy); if (admittedRates.some(admission => admission.policyId === policy.id && admission.versionHash === versionHash)) continue; + // Paired caller/target checks for one delegated test share a slot only + // when the exact policy version and counter bucket are identical. + const shared = options.sharedRateDecision?.policyExplanation?.consumedRateAdmissions; + const bucketKey = `${policy.id}:${rateBucket(rateLimitRule(policy)!, ctx)}`; + if (input.consumeRateLimit === true && Array.isArray(shared) && shared.some(item => isRecord(item) + && item.policyId === policy.id && item.versionHash === versionHash && item.bucketKey === bucketKey)) { + consumedRateAdmissions.push({ policyId: policy.id, versionHash, bucketKey }); + continue; + } const state = await enforceRateLimit(policy, ctx, input.consumeRateLimit === true); if (state?.limited) { return decision("rate_limited", "rate_limited", "Tool access rate limit exceeded.", effectiveProfileIds, [policy.id], { rateLimitState: state, redactionPlan: redaction.redactionPlan, policyExplanation }); } - if (state && input.consumeRateLimit === true) consumedRateAdmissions.push({ policyId: policy.id, versionHash }); + if (state && input.consumeRateLimit === true) consumedRateAdmissions.push({ policyId: policy.id, versionHash, bucketKey: state.bucketKey }); continue; } if (policy.policyType === "trust_rule") { @@ -1723,6 +1754,9 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr issueId: ctx.issueId, runId: ctx.heartbeatRunId, gatewayId: ctx.gatewayId, + ...(input.runContext?.gatewayTokenId ? { gatewayTokenId: sql`(select ${toolMcpGatewayTokens.id} from ${toolMcpGatewayTokens} + where ${toolMcpGatewayTokens.id} = ${input.runContext.gatewayTokenId} and ${toolMcpGatewayTokens.companyId} = ${ctx.companyId} + and ${toolMcpGatewayTokens.gatewayId} = ${ctx.gatewayId})` } : {}), applicationId: ctx.applicationId, connectionId: ctx.connectionId, catalogEntryId: ctx.catalogEntryId, @@ -1753,7 +1787,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr status: "pending", canonicalArgumentsHash: requestIdentityHash!, canonicalArgumentsSummary: redaction.summary, - requestedByAgentId: ctx.actorType === "agent" ? ctx.agentId : null, + requestedByAgentId: ctx.actorType === "agent" ? ctx.actorId : null, requestedByUserId: ctx.actorType === "user" ? ctx.actorId : null, }).returning(); } diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 42645cbf3ce..6cb2220af72 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,5 @@ +import type { ConnectionManagementContext } from "./connection-management.js"; +import { connectionActorUserId } from "./tool-access/actor.js"; import { restConnectorFor } from "./connectors/rest-registry.js"; import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js"; import { createConnectionGrants } from "./tool-access/connections/grants.js"; @@ -1421,7 +1423,7 @@ export function toolAccessService( createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, + connectionActorUserId(actor), }) .returning(); return toProfileBinding(created); @@ -1594,6 +1596,7 @@ export function toolAccessService( input: ConnectToolApp, actor?: ActorInfo, ): Promise { + await actor?.managementContext?.reauthorize(); const definition = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null; @@ -1880,10 +1883,10 @@ export function toolAccessService( previousGrantKind && input.grantKind && previousGrantKind !== input.grantKind && - actor?.actorType === "user" && - actor.actorSource !== "local_implicit" + Boolean(connectionActorUserId(actor)) && + actor?.actorSource !== "local_implicit" ) { - const actorUserId = actor.actorId; + const actorUserId = connectionActorUserId(actor); const [membership] = actorUserId ? await db .select({ @@ -2203,15 +2206,9 @@ export function toolAccessService( const credentialRefs: McpConnectionCredentialRef[] = []; const createdSecretIds: string[] = []; const createdDefinitionIds: string[] = []; - // "Just me" needs a named board user to own the consent. An agent actor - // cannot hold a personal identity, and silently falling back to a shared - // credential is exactly the mis-scoping the design forbids, so refuse. - const personalIdentityUserId = - requestedGrantKind === "user" - ? actor?.actorType === "user" && actor.actorId - ? actor.actorId - : null - : null; + // Personal consent belongs to the authenticated responsible user. A delegated + // agent is recorded separately and never becomes the account owner. + const personalIdentityUserId = requestedGrantKind === "user" ? connectionActorUserId(actor) : null; if (requestedGrantKind === "user" && !personalIdentityUserId) { throw badRequest( "Connecting an app as yourself requires a signed-in user", @@ -2535,7 +2532,7 @@ export function toolAccessService( createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, + connectionActorUserId(actor), }) .returning(); } @@ -3123,6 +3120,7 @@ export function toolAccessService( input: FinishToolApp, actor?: ActorInfo, ): Promise { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(connectionId, companyId); await assertConnectorConnectionSetupEnabled(connection); if (connection.status === "archived") @@ -3228,6 +3226,7 @@ export function toolAccessService( priority: 100, metadata: { source: "app_gallery_finish" }, })); + await actor?.managementContext?.reauthorize(); const transactionResult = await db.transaction(async (tx) => { await tx.select({ id: toolApplications.id }).from(toolApplications) .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) @@ -3409,7 +3408,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 +3429,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 +3445,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 +3468,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 +3549,7 @@ export function toolAccessService( input: { credentialValues: Record }, actor?: ActorInfo, ): Promise { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); await assertConnectorConnectionSetupEnabled(connection); @@ -3781,6 +3781,7 @@ export function toolAccessService( input: CreateToolStdioCommandTemplate, actor?: ActorInfo, ): Promise => { + await actor?.managementContext?.reauthorize(); if (builtInStdioTemplate(input.templateId)) { throw conflict( "A built-in stdio template already uses this templateId", @@ -3807,7 +3808,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 +3858,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 +3882,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 +3946,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, @@ -4083,6 +4086,7 @@ export function toolAccessService( createApplication: async ( companyId: string, input: CreateToolApplication, + managementContext?: ConnectionManagementContext, ): Promise => { await assertOptionalPlugin(input.pluginId); await assertOptionalAgent( @@ -4090,6 +4094,7 @@ export function toolAccessService( input.ownerAgentId, "Tool application owner agent", ); + await managementContext?.reauthorize(); const [row] = await db .insert(toolApplications) .values({ @@ -4126,6 +4131,7 @@ export function toolAccessService( updateApplication: async ( applicationId: string, input: UpdateToolApplication, + managementContext?: ConnectionManagementContext, ): Promise => { const [existing] = await db .select() @@ -4156,6 +4162,7 @@ export function toolAccessService( }); } } + await managementContext?.reauthorize(); const [row] = await db .update(toolApplications) .set({ @@ -4175,6 +4182,7 @@ export function toolAccessService( deleteApplication: async ( applicationId: string, + managementContext?: ConnectionManagementContext, ): Promise => { const [existing] = await db .select() @@ -4198,6 +4206,7 @@ export function toolAccessService( // ON DELETE RESTRICT, so such a delete fails closed with a foreign_key_violation instead // of silently cascading the new connection away. Translate that into the same 409 so the // endpoint keeps its contract instead of surfacing a 500. + await managementContext?.reauthorize(); let row: typeof toolApplications.$inferSelect | undefined; try { [row] = await db @@ -4466,6 +4475,7 @@ export function toolAccessService( input: CreateToolConnection, actor?: ActorInfo, ): Promise => { + await actor?.managementContext?.reauthorize(); let applicationId = input.applicationId; let applicationNamespace = input.applicationName ?? input.name; const transport = input.transport; @@ -4512,6 +4522,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 +4553,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); @@ -4579,8 +4589,15 @@ export function toolAccessService( updateConnection: async ( connectionId: string, input: UpdateToolConnection, + managementContext?: ConnectionManagementContext, ): Promise => { const existing = await getConnectionRow(connectionId); + if (managementContext?.actor.type === "agent" && existing.connectionPurpose === "channel" + && (input.credentialRefs !== undefined || input.credentialSecretRefs !== undefined)) { + throw forbidden("Use chat endpoint setup to change channel credentials; verification keys require human entry", { + code: "chat_endpoint_credentials_require_setup", + }); + } if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed."); const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? existing.config, @@ -4605,6 +4622,7 @@ export function toolAccessService( ...(input.credentialRefs ?? existing.credentialRefs), ...(input.credentialSecretRefs ?? existing.credentialSecretRefs), ]); + await managementContext?.reauthorize(); const write = (database: Db) => database .update(toolConnections) .set({ @@ -4647,13 +4665,14 @@ export function toolAccessService( refreshCatalog, configureRailwaySsh: async (connectionId: string, companyId: string, input: ConfigureRailwaySsh, actor: ActorInfo): Promise => { + await actor.managementContext?.reauthorize(); const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : ""; const setup = await db.transaction(async (tx) => { const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update"); const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update"); if (!connection || !isRailwayConnection(connection) || (connection.status !== "active" && input.action !== "remove")) throw unprocessable("Connect Railway before configuring container access."); if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization."); - if (grant.kind === "user" && (actor.actorType !== "user" || actor.actorId !== grant.subjectUserId)) throw forbidden("Only this authorization's owner can configure its SSH key."); + if (grant.kind === "user" && connectionActorUserId(actor) !== grant.subjectUserId) throw forbidden("Only this authorization's owner can configure its SSH key."); const existing = asRecord(connection.config.railwaySsh); if (existing.grantId && existing.grantId !== grant.id) throw conflict("Remove the existing container key before choosing another authorization."); const currentRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH); diff --git a/server/src/services/tool-access/activity/service.ts b/server/src/services/tool-access/activity/service.ts index 49ff10acdbb..8f5227b8c74 100644 --- a/server/src/services/tool-access/activity/service.ts +++ b/server/src/services/tool-access/activity/service.ts @@ -1,3 +1,5 @@ +import { connectionManagementService } from "../../connection-management.js"; +import type { ConnectionManagementContext } from "../../connection-management.js"; import { and, desc, eq, gte, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, connectionTokenIssuances, authUsers, heartbeatRuns, issues, toolActionRequests, toolConnections, toolCallEvents, toolInvocations } from "@paperclipai/db"; @@ -92,10 +94,11 @@ listConnectionActivity: async ( connectionId: string, companyId?: string, limit = 20, + managementContext?: ConnectionManagementContext, ): Promise => { const connection = await getConnectionRow(connectionId, companyId); const safeLimit = Math.max(1, Math.min(100, Math.floor(limit))); - const rows = await db + let rows = await db .select() .from(toolCallEvents) .where( @@ -106,6 +109,22 @@ listConnectionActivity: async ( ) .orderBy(desc(toolCallEvents.createdAt)) .limit(safeLimit); + if (managementContext?.actor.type === "agent") { + const management = connectionManagementService(db); + const taskVisibility = new Map(); + const visible: typeof rows = []; + for (const row of rows) { + let allowed = taskVisibility.get(row.issueId); + if (allowed === undefined) { + allowed = (await management.capability(managementContext.actor, "connections:audit", { + ...managementContext.resource, connectionId: connection.id, issueId: row.issueId, + }, { mutation: false, readContext: managementContext })).allowed; + taskVisibility.set(row.issueId, allowed); + } + if (allowed) visible.push(row); + } + rows = visible; + } const events = rows.map(toToolCallEvent); const issueIds = [ diff --git a/server/src/services/tool-access/actor.ts b/server/src/services/tool-access/actor.ts index 99147aaaaa1..363433779e9 100644 --- a/server/src/services/tool-access/actor.ts +++ b/server/src/services/tool-access/actor.ts @@ -10,3 +10,7 @@ export function actorBinding(actor: ActorInfo | undefined) { : null, }; } + +export function connectionActorUserId(actor?: ActorInfo): string | null { + return actor?.actorType === "user" ? actor.actorId ?? null : actor?.managementContext?.responsibleUserId ?? null; +} diff --git a/server/src/services/tool-access/catalog/aggregator-accounts.ts b/server/src/services/tool-access/catalog/aggregator-accounts.ts index d83e43c25d4..7c89458bfcd 100644 --- a/server/src/services/tool-access/catalog/aggregator-accounts.ts +++ b/server/src/services/tool-access/catalog/aggregator-accounts.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import { actorForSecret } from "../oauth/credential-store.js"; import type { ActorInfo } from "../types.js"; import { remoteEndpoint, projectedConnectionHeaders } from "../connection-methods.js"; @@ -22,6 +23,12 @@ import type { ConnectionCredentialAccess } from "../credential-access.js"; import type { ToolRemoteHttp } from "../remote-http.js"; import type { ToolCatalogDiscovery } from "./discovery.js"; +function aggregatorUserId(actor: ActorInfo): string { + const userId = connectionActorUserId(actor); + if (!userId) throw forbidden("An authorized responsible user is required"); + return userId; +} + function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) return value as Record; @@ -50,7 +57,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const usesConnectionRefs = ["arcade", "executor"].includes(String(connection.config.sourceTemplateKey)) && connection.authKind !== "oauth" && connection.credentialPolicy === "shared"; const grant = usesConnectionRefs ? null : await vaultGrantForConnection(connection, actor); const refs = grant?.credentialSecretRefs ?? connection.credentialSecretRefs; - const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[actor.actorId!]); + const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[aggregatorUserId(actor)]); const ids = [...new Set([...refs.map(ref => ref.secretId), ...(typeof discovery.secretId === "string" ? [discovery.secretId] : [])])].sort(); const versions = ids.length ? await db.select({ id: companySecrets.id, version: companySecrets.latestVersion }).from(companySecrets) .where(and(eq(companySecrets.companyId, connection.companyId), inArray(companySecrets.id, ids))).orderBy(asc(companySecrets.id)) : []; @@ -64,7 +71,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const credentialKey = await composioCredentialKey(connection, actor); const rows = await db.select().from(toolConnectionAppSnapshots).where(and( eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connection.id), - eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + eq(toolConnectionAppSnapshots.userId, aggregatorUserId(actor)), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), )); return rows.map(row => ({ connectionId: row.connectionId, toolkit: row.toolkit, status: row.status, accounts: row.accounts, checkedAt: row.checkedAt.toISOString(), errorAt: row.errorAt?.toISOString() ?? null })); @@ -74,7 +81,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const credentialKey = await composioCredentialKey(connection, actor); const [sync] = await db.select().from(toolConnectionAppSyncs).where(and( eq(toolConnectionAppSyncs.companyId, connection.companyId), eq(toolConnectionAppSyncs.connectionId, connection.id), - eq(toolConnectionAppSyncs.userId, actor.actorId!), eq(toolConnectionAppSyncs.credentialKey, credentialKey), + eq(toolConnectionAppSyncs.userId, aggregatorUserId(actor)), eq(toolConnectionAppSyncs.credentialKey, credentialKey), )); const interrupted = sync?.status === "syncing" && sync.updatedAt.getTime() < Date.now() - composioSyncLeaseMs; return { apps: await cachedComposioApps(connection, actor), sync: { @@ -88,7 +95,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( @@ -113,7 +121,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const names = toolkits.filter(tool => tool.action === "list").map(tool => tool.name); if (names.length) await db.update(toolConnectionAppSnapshots).set({ errorAt: checkedAt }).where(and( eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), - eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + eq(toolConnectionAppSnapshots.userId, aggregatorUserId(actor)), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), inArray(toolConnectionAppSnapshots.toolkit, names), lte(toolConnectionAppSnapshots.checkedAt, checkedAt), )); }; @@ -143,7 +151,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred } catch (error) { await db.update(toolConnectionAppSnapshots).set({ errorAt: checkedAt }).where(and( eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), - eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + eq(toolConnectionAppSnapshots.userId, aggregatorUserId(actor)), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), eq(toolConnectionAppSnapshots.toolkit, tool.name), lte(toolConnectionAppSnapshots.checkedAt, checkedAt), )); if (!tolerateToolkitErrors) throw error; @@ -151,7 +159,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred continue; } const status = accounts.some(account => account.status === "ACTIVE") ? "connected" as const : "not_connected" as const; - await db.insert(toolConnectionAppSnapshots).values({ companyId: connection.companyId, connectionId, userId: actor.actorId!, + await db.insert(toolConnectionAppSnapshots).values({ companyId: connection.companyId, connectionId, userId: aggregatorUserId(actor), credentialKey, toolkit: tool.name, accounts, status, checkedAt, errorAt: null, }).onConflictDoUpdate({ target: [toolConnectionAppSnapshots.companyId, toolConnectionAppSnapshots.connectionId, toolConnectionAppSnapshots.userId, toolConnectionAppSnapshots.toolkit], set: { accounts, status, checkedAt, credentialKey, errorAt: null }, setWhere: lte(toolConnectionAppSnapshots.checkedAt, checkedAt) }); @@ -171,7 +179,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const leaseId = randomUUID(); const startedAt = new Date(); const [lease] = await db.insert(toolConnectionAppSyncs).values({ companyId: connection.companyId, connectionId, - userId: actor.actorId!, credentialKey, leaseId, status: "syncing", total: toolkits.length, updatedAt: startedAt, + userId: aggregatorUserId(actor), credentialKey, leaseId, status: "syncing", total: toolkits.length, updatedAt: startedAt, }).onConflictDoUpdate({ target: [toolConnectionAppSyncs.companyId, toolConnectionAppSyncs.connectionId, toolConnectionAppSyncs.userId, toolConnectionAppSyncs.credentialKey], set: { leaseId, status: "syncing", checked: 0, total: toolkits.length, failed: 0, updatedAt: startedAt }, setWhere: and( @@ -207,7 +215,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred // Preserve observations, but never present a failed check as current authorization. await db.update(toolConnectionAppSnapshots).set({ errorAt: new Date() }).where(and( eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), - eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + eq(toolConnectionAppSnapshots.userId, aggregatorUserId(actor)), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), lte(toolConnectionAppSnapshots.checkedAt, startedAt), )); await updateLease({ status: "error", failed }).catch(() => undefined); @@ -225,15 +233,16 @@ 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 { const provider = aggregatorProvider(connection); if (connection.status !== "active" || !connection.enabled) return { availability: "disabled", message: "Restore this gateway to check its accounts." }; if (provider === "arcade") { - const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[actor.actorId!]); + const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[aggregatorUserId(actor)]); const headers = projectedConnectionHeaders(connection); const grant = connection.authKind === "oauth" || connection.credentialPolicy !== "shared" ? await vaultGrantForConnection(connection, actor) : null; const refs = grant?.credentialSecretRefs ?? connection.credentialSecretRefs; @@ -254,7 +263,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const credentialKey = await composioCredentialKey(connection, actor); const [sync] = await db.select({ status: toolConnectionAppSyncs.status }).from(toolConnectionAppSyncs).where(and( eq(toolConnectionAppSyncs.companyId, connection.companyId), eq(toolConnectionAppSyncs.connectionId, connection.id), - eq(toolConnectionAppSyncs.userId, actor.actorId!), eq(toolConnectionAppSyncs.credentialKey, credentialKey), + eq(toolConnectionAppSyncs.userId, aggregatorUserId(actor)), eq(toolConnectionAppSyncs.credentialKey, credentialKey), )); if (sync?.status === "unsupported") return { availability: "unsupported", message: "Executor account discovery is unavailable on this server. You can still use the gateway’s actions." }; } @@ -262,7 +271,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred } async function aggregatorAppsResponse(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise { - assertDiscoveryActor(actor); + await assertDiscoveryActor(actor); const provider = aggregatorProvider(connection); const result = await composioAppsResponse(connection, actor); const discovery = await aggregatorDiscoveryState(connection, actor); @@ -283,14 +292,14 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred if (provider === "arcade") { // The extra project credential never joins invocation headers or tool grants. if (endpoint.hostname !== "api.arcade.dev" || endpoint.protocol !== "https:") throw unprocessable("Account sync requires an Arcade Cloud gateway"); - const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[actor.actorId!]); + const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[aggregatorUserId(actor)]); let key: string | undefined; let userId: string | undefined; if (typeof discovery.secretId === "string" && typeof discovery.userId === "string") { - const [secret] = await db.select().from(companySecrets).where(and(eq(companySecrets.id, discovery.secretId), eq(companySecrets.companyId, connection.companyId), eq(companySecrets.ownerUserId, actor.actorId!))); + const [secret] = await db.select().from(companySecrets).where(and(eq(companySecrets.id, discovery.secretId), eq(companySecrets.companyId, connection.companyId), eq(companySecrets.ownerUserId, aggregatorUserId(actor)))); if (!secret?.userSecretDefinitionId) throw forbidden("Your Arcade discovery credential is unavailable"); - const resolved = await secrets.resolveUserSecretValue(connection.companyId, { definitionId: secret.userSecretDefinitionId, responsibleUserId: actor.actorId!, required: true }, { - actorType: "user", actorId: actor.actorId!, consumerType: "tool_connection", consumerId: connection.id, responsibleUserId: actor.actorId!, + const resolved = await secrets.resolveUserSecretValue(connection.companyId, { definitionId: secret.userSecretDefinitionId, responsibleUserId: aggregatorUserId(actor), required: true }, { + actorType: actor.actorType === "agent" ? "agent" : "user", actorId: actor.actorId!, heartbeatRunId: actor.managementContext?.workContext.runId, consumerType: "tool_connection", consumerId: connection.id, responsibleUserId: aggregatorUserId(actor), }); key = resolved?.value; userId = discovery.userId; @@ -322,7 +331,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred } async function syncAggregatorApps(connectionId: string, force: boolean, actor: ActorInfo): Promise { - assertDiscoveryActor(actor); + await assertDiscoveryActor(actor); let connection = await getConnectionRow(connectionId); const provider = aggregatorProvider(connection); const discovery = await aggregatorDiscoveryState(connection, actor, force); @@ -337,7 +346,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const credentialKey = await composioCredentialKey(connection, actor); const leaseId = randomUUID(); const startedAt = new Date(); - const [lease] = await db.insert(toolConnectionAppSyncs).values({ companyId: connection.companyId, connectionId, userId: actor.actorId!, credentialKey, leaseId, status: "syncing", updatedAt: startedAt }) + const [lease] = await db.insert(toolConnectionAppSyncs).values({ companyId: connection.companyId, connectionId, userId: aggregatorUserId(actor), credentialKey, leaseId, status: "syncing", updatedAt: startedAt }) .onConflictDoUpdate({ target: [toolConnectionAppSyncs.companyId, toolConnectionAppSyncs.connectionId, toolConnectionAppSyncs.userId, toolConnectionAppSyncs.credentialKey], set: { leaseId, status: "syncing", checked: 0, total: 0, failed: 0, updatedAt: startedAt }, setWhere: and(or(ne(toolConnectionAppSyncs.status, "syncing"), lt(toolConnectionAppSyncs.updatedAt, new Date(Date.now() - composioSyncLeaseMs))), @@ -355,9 +364,9 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred const checkedAt = new Date(); // Commit the entire enumeration at once; a failed later page cannot delete accounts. await tx.update(toolConnectionAppSnapshots).set({ accounts: [], status: "not_connected", checkedAt, errorAt: null }).where(and( - eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), eq(toolConnectionAppSnapshots.userId, aggregatorUserId(actor)), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), )); - for (const app of apps) await tx.insert(toolConnectionAppSnapshots).values({ companyId: connection.companyId, connectionId, userId: actor.actorId!, credentialKey, + for (const app of apps) await tx.insert(toolConnectionAppSnapshots).values({ companyId: connection.companyId, connectionId, userId: aggregatorUserId(actor), credentialKey, toolkit: app.toolkit, accounts: app.accounts, status: app.accounts.some(account => account.status === "ACTIVE") ? "connected" : "not_connected", checkedAt, }).onConflictDoUpdate({ target: [toolConnectionAppSnapshots.companyId, toolConnectionAppSnapshots.connectionId, toolConnectionAppSnapshots.userId, toolConnectionAppSnapshots.toolkit], set: { accounts: app.accounts, status: app.accounts.some(account => account.status === "ACTIVE") ? "connected" : "not_connected", checkedAt, errorAt: null, credentialKey }, @@ -368,7 +377,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred await db.transaction(async tx => { const [active] = await tx.select().from(toolConnectionAppSyncs).where(and(eq(toolConnectionAppSyncs.id, lease.id), eq(toolConnectionAppSyncs.leaseId, leaseId))).for("update"); if (!active) return; - await tx.update(toolConnectionAppSnapshots).set({ errorAt: new Date() }).where(and(eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey))); + await tx.update(toolConnectionAppSnapshots).set({ errorAt: new Date() }).where(and(eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), eq(toolConnectionAppSnapshots.userId, aggregatorUserId(actor)), eq(toolConnectionAppSnapshots.credentialKey, credentialKey))); await tx.update(toolConnectionAppSyncs).set({ status: error instanceof AggregatorDiscoveryUnavailableError ? "unsupported" : "error", failed: 1, updatedAt: new Date() }).where(eq(toolConnectionAppSyncs.id, lease.id)); }); } @@ -380,23 +389,24 @@ 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"); if (!connection || aggregatorProvider(connection) !== "arcade" || connection.status === "archived") throw notFound("Arcade gateway not found"); const all = asRecord(connection.config.aggregatorDiscovery); - const before = asRecord(all[actor.actorId!]); + const before = asRecord(all[aggregatorUserId(actor)]); const { secret } = await writeConnectionCredential(tx, { companyId: connection.companyId, connectionName: connection.name, configPath: "aggregatorDiscovery.apiKey", label: "Arcade account sync API key", - value: input.apiKey, ownerUserId: actor.actorId!, actor: actorForSecret(actor), + value: input.apiKey, ownerUserId: aggregatorUserId(actor), actor: actorForSecret(actor), existingRef: typeof before.secretId === "string" ? { secretId: before.secretId, configPath: "aggregatorDiscovery.apiKey", versionSelector: "latest" } : undefined, definitionKey: `arcade_discovery.${connection.id}`, }); - const config = { ...connection.config, aggregatorDiscovery: { ...all, [actor.actorId!]: { userId: input.userId, secretId: secret.id } } }; + const config = { ...connection.config, aggregatorDiscovery: { ...all, [aggregatorUserId(actor)]: { userId: input.userId, secretId: secret.id } } }; await tx.update(toolConnections).set({ config, transportConfig: config, updatedAt: new Date() }).where(eq(toolConnections.id, connectionId)); }); - await logActivity(db, { companyId, actorType: "user", actorId: actor.actorId!, action: "tool_connection.account_sync_configured", entityType: "tool_connection", entityId: connectionId, details: { provider: "arcade" } }); + await logActivity(db, { companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.actorId! }), action: "tool_connection.account_sync_configured", entityType: "tool_connection", entityId: connectionId, details: { provider: "arcade" } }); return syncAggregatorApps(connectionId, true, actor); } return { @@ -422,6 +432,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 +445,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); @@ -463,7 +476,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred if (!before?.some(account => account.id === input.accountId)) throw notFound("This account is no longer available in the selected Composio app"); } const details = { toolkit, ...(input.action !== "add" ? { accountId: input.accountId } : {}) }; - await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: actor.actorId!, + await logActivity(db, { companyId: connection.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.actorId! }), action: `tool_connection.upstream_account_${input.action}_requested`, entityType: "tool_connection", entityId: connectionId, details }); let payload: unknown; try { @@ -483,7 +496,7 @@ export function createAggregatorAccounts(db: Db, { options, secrets, audit, cred throw conflict("Composio has not confirmed this change. Refresh the accounts before trying again."); } } - await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: actor.actorId!, + await logActivity(db, { companyId: connection.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.actorId! }), action: `tool_connection.upstream_account_${input.action}`, entityType: "tool_connection", entityId: connectionId, details: { toolkit, ...(input.action !== "add" ? { accountId: input.accountId } : {}) } }); return { ...result, apps: await cachedComposioApps(connection, actor) }; diff --git a/server/src/services/tool-access/catalog/health.ts b/server/src/services/tool-access/catalog/health.ts index 2cc4df48431..3a0eb124240 100644 --- a/server/src/services/tool-access/catalog/health.ts +++ b/server/src/services/tool-access/catalog/health.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import { assertSupportedConnection } from "../connections/setup-policy.js"; import { connectionSetupUrl, connectionReconnectUrl } from "../identifiers.js"; import { createGitHubGrantContinuity } from "../github-grant-continuity.js"; @@ -100,16 +101,16 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d ), ); const actorGrant = - actor?.actorType === "user" + connectionActorUserId(actor) ? activeGrants.find( (grant) => grant.kind === "user" && - grant.subjectUserId === actor.actorId, + grant.subjectUserId === connectionActorUserId(actor), ) : null; const grantsToCheck = connection.credentialPolicy === "per_user" && - actor?.actorType === "user" + Boolean(connectionActorUserId(actor)) ? actorGrant ? [actorGrant] : [] @@ -132,8 +133,8 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d connection.status === "draft" && connection.authKind === "none" && connection.credentialPolicy === "per_user" && - actor?.actorType === "user" && - actor.actorId === connection.createdByUserId; + Boolean(connectionActorUserId(actor)) && + connectionActorUserId(actor) === connection.createdByUserId; const credentialHeaders = connection.credentialSource === "vercel_connect" ? await resolveCredentialHeaders(connection, actor, { diff --git a/server/src/services/tool-access/catalog/publication.ts b/server/src/services/tool-access/catalog/publication.ts index 113e227cbf5..84459758207 100644 --- a/server/src/services/tool-access/catalog/publication.ts +++ b/server/src/services/tool-access/catalog/publication.ts @@ -58,6 +58,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection credentialHeaders?: Record; } = {}, ): Promise { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog"); await assertConnectorConnectionSetupEnabled(connection); diff --git a/server/src/services/tool-access/connections/grants.ts b/server/src/services/tool-access/connections/grants.ts index c6b5aea2706..1836a6a52fc 100644 --- a/server/src/services/tool-access/connections/grants.ts +++ b/server/src/services/tool-access/connections/grants.ts @@ -1,3 +1,5 @@ +import type { ConnectionManagementContext } from "../../connection-management.js"; +import { connectionActorUserId } from "../actor.js"; import { toConnectionGrant } from "./projection.js"; import { actorBinding } from "../actor.js"; import { vercelConnectResourcesFor } from "../vercel-credentials.js"; @@ -125,6 +127,7 @@ replaceConnectionGrantMembers: async ( memberUserIds: string[], actor?: ActorInfo, ) => { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(idOrUid); const [grant] = await db .select() @@ -278,7 +281,9 @@ replaceConnectionGrantMembers: async ( grantId: string, agentId: string, ownerUserId: string, + managementContext?: ConnectionManagementContext, ) => { + await managementContext?.reauthorize(); const connection = await getConnectionRow(idOrUid); if (connection.connectionPurpose === "ai") { throw badRequest("AI credentials use the connection's human access settings, not agent delegation"); @@ -363,12 +368,13 @@ replaceConnectionGrantMembers: async ( await tx.insert(toolAccessAuditEvents).values({ companyId: connection.companyId, connectionId: connection.id, - actorType: "user", - actorId: ownerUserId, + actorType: managementContext?.audit.actorType ?? "user", + actorId: managementContext?.audit.actorId ?? ownerUserId, action: "connection_grant.delegated", outcome: "success", reasonCode: "delegation_created", - details: { grantId: grant.id, delegationId: delegation!.id, agentId }, + details: { grantId: grant.id, delegationId: delegation!.id, agentId, + ...(managementContext ? { responsibleUserId: managementContext.responsibleUserId, runId: managementContext.actor.runId, agentApiKeyId: managementContext.actor.keyId } : {}) }, }); return delegation!; }); @@ -379,6 +385,7 @@ replaceConnectionGrantMembers: async ( delegationId: string, actor?: ActorInfo, ) => { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(idOrUid); const delegation = await db.transaction(async tx => { const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants) @@ -425,6 +432,7 @@ replaceConnectionGrantMembers: async ( }, actor?: ActorInfo, ) => { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(idOrUid); await assertSecretRefs( connection.companyId, @@ -454,8 +462,7 @@ replaceConnectionGrantMembers: async ( isDefault: input.isDefault ?? false, createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, - createdByUserId: - binding.actorType === "user" ? binding.actorId : null, + createdByUserId: connectionActorUserId(actor), }) .returning(); if (!grant) throw new Error("Failed to create connection installation"); @@ -480,6 +487,7 @@ replaceConnectionGrantMembers: async ( grantId: string, actor?: ActorInfo, ) => { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(idOrUid); const binding = actorBinding(actor); const [currentGrant] = await db diff --git a/server/src/services/tool-access/connections/installs.ts b/server/src/services/tool-access/connections/installs.ts index 2879cf8af8f..e16cda72c2c 100644 --- a/server/src/services/tool-access/connections/installs.ts +++ b/server/src/services/tool-access/connections/installs.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import { toConnection, toConnectionInstall } from "./projection.js"; import { actorBinding } from "../actor.js"; import { lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "../assignment-locks.js"; @@ -147,6 +148,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert input: PutToolConnectionInstalls, actor?: ActorInfo, ): Promise => { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(connectionId); const requested = new Map( input.installs.map((install) => [ @@ -267,7 +269,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, + connectionActorUserId(actor), })), ); } @@ -289,7 +291,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, + connectionActorUserId(actor), }) .onConflictDoNothing() .returning({ id: toolProfileBindings.id }); diff --git a/server/src/services/tool-access/connections/personal-identity.ts b/server/src/services/tool-access/connections/personal-identity.ts index 1d12a5b5137..628ad1fa957 100644 --- a/server/src/services/tool-access/connections/personal-identity.ts +++ b/server/src/services/tool-access/connections/personal-identity.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import type { ActorInfo } from "../types.js"; import { and, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -47,7 +48,7 @@ async function fixedPersonalIdentityForReconnect( const fixedSubjectUserId = connection.createdByUserId ?? retainedGrant?.subjectUserId ?? null; const binding = actorBinding(actor); - const actorUserId = binding.actorType === "user" ? binding.actorId : null; + const actorUserId = connectionActorUserId(actor); const subjectUserId = requestedSubjectUserId ?? actorUserId; if (!subjectUserId) { diff --git a/server/src/services/tool-access/connections/removal.ts b/server/src/services/tool-access/connections/removal.ts index 71ab1767558..6679be5ced7 100644 --- a/server/src/services/tool-access/connections/removal.ts +++ b/server/src/services/tool-access/connections/removal.ts @@ -228,6 +228,7 @@ async function removeConnection( companyId?: string, actor?: ActorInfo, ): Promise { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(connectionId, companyId); const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack"))); if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor); @@ -243,6 +244,7 @@ async function removeConnection( } async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise { + await actor?.managementContext?.reauthorize(); const connection = await getConnectionRow(connectionId, companyId); forgetMcpHttpSessions(connection.id); const now = new Date(); diff --git a/server/src/services/tool-access/credential-access.ts b/server/src/services/tool-access/credential-access.ts index 63d0919ddff..65551c7aa30 100644 --- a/server/src/services/tool-access/credential-access.ts +++ b/server/src/services/tool-access/credential-access.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "./actor.js"; import type { ActorInfo } from "./types.js"; import { oauthConfig } from "./oauth/metadata.js"; import { remoteEndpoint } from "./connection-methods.js"; @@ -39,7 +40,7 @@ export function createConnectionCredentialAccess(db: Db, { secrets, audit, verce actor?: ActorInfo, ): Promise { const actorUserId = - actor?.actorType === "user" ? (actor.actorId ?? null) : null; + connectionActorUserId(actor); if (actorUserId) { const [personal] = await db .select() diff --git a/server/src/services/tool-access/github-grant-continuity.ts b/server/src/services/tool-access/github-grant-continuity.ts index 31691bd7167..568a0a00a99 100644 --- a/server/src/services/tool-access/github-grant-continuity.ts +++ b/server/src/services/tool-access/github-grant-continuity.ts @@ -30,6 +30,7 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector initialGrant: typeof connectionGrants.$inferSelect, actor?: ActorInfo, ) { + await actor?.managementContext?.reauthorize(); return measureRunPhase("connectors.github.refresh", async () => { try { return await refreshManagedGitHubGrantAccessOnce( @@ -64,6 +65,7 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector initialGrant: typeof connectionGrants.$inferSelect, actor?: ActorInfo, ) { + await actor?.managementContext?.reauthorize(); let grant = await measureRunPhase("connectors.github.credentials", () => refreshOAuthGrantCredentials({ companyId: connection.companyId, connectionId: connection.id, diff --git a/server/src/services/tool-access/oauth/access-finalization.ts b/server/src/services/tool-access/oauth/access-finalization.ts index e27e8a67a0d..2b5d2265a6a 100644 --- a/server/src/services/tool-access/oauth/access-finalization.ts +++ b/server/src/services/tool-access/oauth/access-finalization.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import type { ActorInfo } from "../types.js"; import { connectionMethodForConnection } from "../connection-methods.js"; import { emitConnectionUpdated } from "../../connector-telemetry.js"; @@ -38,6 +39,7 @@ async function finalizeOAuthAccess( actor?: ActorInfo, requestingAgentId?: string, ): Promise { + await actor?.managementContext?.reauthorize(); if (requestingAgentId) await assertAgentsInCompany(companyId, [requestingAgentId]); let connection = await getConnectionRow(connectionId, companyId); @@ -49,7 +51,7 @@ async function finalizeOAuthAccess( throw badRequest("This connection does not use browser sign-in"); if (connection.status === "archived") throw conflict("Archived app connections cannot be finished"); - const actorUserId = actor?.actorType === "user" ? actor.actorId : null; + const actorUserId = connectionActorUserId(actor); if (!actorUserId) throw badRequest("Finishing browser sign-in requires a signed-in user"); diff --git a/server/src/services/tool-access/oauth/authorization-start.ts b/server/src/services/tool-access/oauth/authorization-start.ts index 823b79eb5ef..0972611c16b 100644 --- a/server/src/services/tool-access/oauth/authorization-start.ts +++ b/server/src/services/tool-access/oauth/authorization-start.ts @@ -9,10 +9,10 @@ import { connectionMethodForConnection } from "../connection-methods.js"; import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared"; import { and, eq, lt } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db"; +import { companies, agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db"; import type { ConnectionGrantKind, ToolOAuthStartResult } from "@paperclipai/shared"; import { getConnectableAppDefinition } from "@paperclipai/shared"; -import { badRequest, conflict, forbidden, unprocessable } from "../../../errors.js"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../errors.js"; import { isPaperclipCloudConnectorStrategy, type PaperclipCloudConnector } from "../../paperclip-cloud-connector.js"; import { deriveVercelConnectSubject, vercelConnectCallbackUrl, VercelConnectClientError, type VercelConnectClient } from "../../vercel-connect.js"; @@ -53,6 +53,7 @@ export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies interactionId?: string; }, ): Promise { + await input.actor.managementContext?.reauthorize(); let connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); await assertConnectorConnectionSetupEnabled(connection); @@ -170,13 +171,43 @@ 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. + if (sourceTemplateKey === "google-workspace" && (input.workspaceServices || input.scopes)) { + await input.actor.managementContext.reauthorize(); + const userId = input.actor.managementContext.responsibleUserId!; + await db.transaction(async tx => { + const [saved] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).for("no key update"); + if (!saved) throw notFound("Connection not found"); + const rawOauth = saved.config.oauth; + const oauth = rawOauth && typeof rawOauth === "object" && !Array.isArray(rawOauth) ? rawOauth as Record : {}; + const prior = oauth.pendingWorkspaceServiceRequests; + const pending = prior && typeof prior === "object" && !Array.isArray(prior) ? prior as Record : {}; + const requested = { ...pending, [userId]: { requestedScopes, requestedAt: now().toISOString() } }; + const config = { ...saved.config, oauth: { ...oauth, pendingWorkspaceServiceRequests: requested } }; + const transportOauth = saved.transportConfig.oauth; + const transportConfig = { ...saved.transportConfig, oauth: { ...(transportOauth && typeof transportOauth === "object" && !Array.isArray(transportOauth) ? transportOauth : {}), pendingWorkspaceServiceRequests: requested } }; + await tx.update(toolConnections).set({ config, transportConfig, updatedAt: now() }).where(eq(toolConnections.id, saved.id)); + }); + } + const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)); + if (!company) throw notFound("Company not found"); + const destination = new URL(input.redirectUri); + destination.pathname = `/${encodeURIComponent(company.prefix)}/apps/${connection.credentialSource === "vercel_connect" ? "vercel-connect" : "connect"}`; + destination.search = new URLSearchParams({ [connection.status === "draft" ? "resume" : "reconnect"]: connection.id, + ...(sourceTemplateKey ? { source: sourceTemplateKey } : {}), ...(intentLink ? { intent: intentLink.id } : {}) }).toString(); + destination.hash = ""; + return { connectionId: connection.id, provider: sourceTemplateKey ?? "oauth", + authorizationUrl: destination.toString(), expiresAt: null }; + } if (connection.credentialSource === "vercel_connect") { if (!vercelConnect) throw vercelConnectHttpError( diff --git a/server/src/services/tool-access/oauth/catalog-completion.ts b/server/src/services/tool-access/oauth/catalog-completion.ts index 95d293cd1b2..b655c764915 100644 --- a/server/src/services/tool-access/oauth/catalog-completion.ts +++ b/server/src/services/tool-access/oauth/catalog-completion.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import type { ActorInfo } from "../types.js"; import { isRemoteMcpConnectorMethod } from "@paperclipai/shared"; import { and, eq } from "drizzle-orm"; @@ -138,9 +139,7 @@ export function createOAuthCatalogCompletion(db: Db, finishGalleryAppConnection: ? (input.actor.actorId ?? null) : null, createdByUserId: - input.actor?.actorType === "user" - ? (input.actor.actorId ?? null) - : null, + connectionActorUserId(input.actor), })), ) .onConflictDoNothing(); diff --git a/server/src/services/tool-access/oauth/connection-refresh.ts b/server/src/services/tool-access/oauth/connection-refresh.ts index 959d0619c00..f6127cb9581 100644 --- a/server/src/services/tool-access/oauth/connection-refresh.ts +++ b/server/src/services/tool-access/oauth/connection-refresh.ts @@ -366,6 +366,7 @@ const oauthRefreshFlights = new Map>(); heartbeatRunId?: string | null; }, ): Promise { + await actor?.managementContext?.reauthorize(); const oauth = oauthConfig(connection); if ( typeof oauth.tokenUrl !== "string" || diff --git a/server/src/services/tool-access/oauth/credential-store.ts b/server/src/services/tool-access/oauth/credential-store.ts index 9e786c891f9..a4d59570235 100644 --- a/server/src/services/tool-access/oauth/credential-store.ts +++ b/server/src/services/tool-access/oauth/credential-store.ts @@ -1,3 +1,4 @@ +import { connectionActorUserId } from "../actor.js"; import type { ActorInfo, DbTransaction, ToolAccessMutationDb } from "../types.js"; import { oauthConfig } from "./metadata.js"; import { syncConnectionCredentialBindings } from "../../connection-credential-bindings.js"; @@ -80,8 +81,7 @@ async function syncCredentialBindings( let credentialSecretRefs = connection.credentialSecretRefs; if ( connection.credentialPolicy === "per_user" && - actor?.actorType === "user" && - actor.actorId && + Boolean(connectionActorUserId(actor)) && !credentialSecretRefs.some( (ref) => ref.configPath === "oauth.client_secret", ) @@ -96,7 +96,7 @@ async function syncCredentialBindings( eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, actor.actorId), + eq(connectionGrants.subjectUserId, connectionActorUserId(actor)!), eq(connectionGrants.status, "active"), ), ) diff --git a/server/src/services/tool-access/profiles/service.ts b/server/src/services/tool-access/profiles/service.ts index ed24716410c..5a208089025 100644 --- a/server/src/services/tool-access/profiles/service.ts +++ b/server/src/services/tool-access/profiles/service.ts @@ -1,3 +1,5 @@ +import type { ConnectionManagementContext } from "../../connection-management.js"; +import { connectionActorUserId } from "../actor.js"; import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js"; import { randomUUID } from "node:crypto"; import { and, asc, desc, eq, inArray } from "drizzle-orm"; @@ -156,6 +158,7 @@ export function createToolProfileService(db: Db, now: () => Date) { input: ReviewToolProfileNewTools, actor?: ActorInfo, ): Promise { + await actor?.managementContext?.reauthorize(); const profile = await getProfileRow(profileId); const review = await listProfileNewTools(profile.id, profile.companyId); if (review.tools.length === 0) @@ -213,7 +216,7 @@ export function createToolProfileService(db: Db, now: () => Date) { reviewedByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, reviewedByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, + connectionActorUserId(actor), updatedAt: nowAt, }) .where( @@ -460,8 +463,9 @@ export function createToolProfileService(db: Db, now: () => Date) { createProfile: async ( companyId: string, - input: CreateToolProfileWithEntries, + input: CreateToolProfileWithEntries, managementContext?: ConnectionManagementContext, ): Promise => { + await managementContext?.reauthorize(); await assertProfileEntryInputs(db, companyId, input.entries ?? []); const [row] = await db .insert(toolProfiles) @@ -487,8 +491,9 @@ export function createToolProfileService(db: Db, now: () => Date) { updateProfile: async ( profileId: string, - input: UpdateToolProfileWithEntries, + input: UpdateToolProfileWithEntries, managementContext?: ConnectionManagementContext, ): Promise => { + await managementContext?.reauthorize(); const existing = await getProfileRow(profileId); if (input.entries) { await assertProfileEntryInputs(db, existing.companyId, input.entries); @@ -538,8 +543,9 @@ export function createToolProfileService(db: Db, now: () => Date) { duplicateProfile: async ( profileId: string, - input: DuplicateToolProfile, + input: DuplicateToolProfile, managementContext?: ConnectionManagementContext, ): Promise => { + await managementContext?.reauthorize(); const existing = await getProfileRow(profileId); const [entries, bindings] = await Promise.all([ db @@ -614,13 +620,14 @@ export function createToolProfileService(db: Db, now: () => Date) { deleteProfile: async ( profileId: string, - input: DeleteToolProfile, + input: DeleteToolProfile, managementContext?: ConnectionManagementContext, ): Promise<{ profile: ToolProfile; summary: ToolProfileSummary; reassignedToProfileId: string | null; reassignedBindingCount: number; }> => db.transaction(async tx => { + await managementContext?.reauthorize(); const existing = await getProfileRow(profileId, undefined, tx); const db = tx as unknown as Db; await tx.select({ id: toolProfiles.id }).from(toolProfiles) @@ -715,8 +722,9 @@ export function createToolProfileService(db: Db, now: () => Date) { addProfileEntry: async ( profileId: string, - input: CreateToolProfileEntryForProfile, + input: CreateToolProfileEntryForProfile, managementContext?: ConnectionManagementContext, ): Promise => db.transaction(async tx => { + await managementContext?.reauthorize(); const db = tx as unknown as Db; const profile = await getProfileRow(profileId, undefined, tx); await assertProfileEntryInputs(tx, profile.companyId, [input]); @@ -755,8 +763,9 @@ export function createToolProfileService(db: Db, now: () => Date) { updateProfileEntry: async ( entryId: string, - input: UpdateToolProfileEntry, + input: UpdateToolProfileEntry, managementContext?: ConnectionManagementContext, ): Promise => db.transaction(async tx => { + await managementContext?.reauthorize(); const db = tx as unknown as Db; const [existing] = await db .select() @@ -798,7 +807,8 @@ export function createToolProfileService(db: Db, now: () => Date) { return toProfileEntry(row); }), - deleteProfileEntry: async (entryId: string): Promise => db.transaction(async tx => { + deleteProfileEntry: async (entryId: string, managementContext?: ConnectionManagementContext): Promise => db.transaction(async tx => { + await managementContext?.reauthorize(); const db = tx as unknown as Db; const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId)); if (!existing) throw notFound("Tool profile entry not found"); @@ -820,6 +830,7 @@ export function createToolProfileService(db: Db, now: () => Date) { input: CreateToolProfileBindingForProfile, actor?: ActorInfo, ): Promise => db.transaction(async tx => { + await actor?.managementContext?.reauthorize(); const db = tx as unknown as Db; const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); if (!profile) throw notFound("Tool profile not found"); @@ -841,7 +852,7 @@ export function createToolProfileService(db: Db, now: () => Date) { createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, + connectionActorUserId(actor), }) .returning(); await db @@ -853,8 +864,9 @@ export function createToolProfileService(db: Db, now: () => Date) { unbindProfile: async ( profileId: string, - input: UnbindToolProfileBinding, + input: UnbindToolProfileBinding, managementContext?: ConnectionManagementContext, ): Promise<{ unbound: number }> => db.transaction(async tx => { + await managementContext?.reauthorize(); const db = tx as unknown as Db; const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); if (!profile) throw notFound("Tool profile not found"); diff --git a/server/src/services/tool-access/runtime/slots.ts b/server/src/services/tool-access/runtime/slots.ts index 3ca8e6b4d81..fab56ccece9 100644 --- a/server/src/services/tool-access/runtime/slots.ts +++ b/server/src/services/tool-access/runtime/slots.ts @@ -114,6 +114,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick { + await input.actor?.managementContext?.reauthorize(); try { if (input.action === "stop") { await runtimeSupervisor.stopSlot({ @@ -132,6 +133,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick[0]>[0]; export type ToolAccessMutationDb = Pick< diff --git a/server/src/services/tool-action-review.ts b/server/src/services/tool-action-review.ts index 3d0effa9f07..a1735487f0b 100644 --- a/server/src/services/tool-action-review.ts +++ b/server/src/services/tool-action-review.ts @@ -115,7 +115,7 @@ export async function commitToolActionReview( !invocation || invocation.issueId !== current.issueId || (current.requestedByAgentId && - invocation.agentId !== current.requestedByAgentId) + (invocation.actorType === "agent" ? invocation.actorId : invocation.agentId) !== current.requestedByAgentId) ) throw conflict("Tool invocation context does not match"); if (current.expiresAt && current.expiresAt <= new Date()) diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 68abd9e014f..d2d703dfcd6 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -4,6 +4,7 @@ import { promptInjectionPreventionService } from "./prompt-injection-prevention. import { adapterExecutionControls } from "./adapter-execution-control.js"; import type { BudgetServiceHooks } from "./budgets.js"; import type { PromptInjectionDetectorMetadata } from "@paperclipai/shared"; +import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js"; import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js"; import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js"; import { nativeProviderCatalogFor } from "./native-provider-catalogs.js"; @@ -15,6 +16,7 @@ import { browserUseService } from "./browser-use.js"; import { isBrowserUseConnection } from "./browser-use-client.js"; import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js"; import { HttpError } from "../errors.js"; +import { authorizationService, canActorReadIssuePrivacy, type AuthorizationActor } from "./authorization.js"; import { claimSlackRateLimitRetry } from "./connectors/slack-retry.js"; import { resolveSlackTaskAuthority } from "./connectors/slack-authority.js"; import { SLACK_TOOLS } from "@paperclipai/shared"; @@ -338,6 +340,7 @@ export interface ToolGatewaySession { runId: string | null; issueId: string | null; projectId: string | null; + originatingAgentId?: string | null; gatewayId?: string | null; gatewayPublicId?: string | null; gatewayName?: string | null; @@ -404,6 +407,7 @@ interface ExecuteGatewayToolInput { interface ExecuteTestCallInput { signal?: AbortSignal; + managementContext?: ConnectionManagementContext; companyId: string; connectionId: string; agentId: string; @@ -1192,11 +1196,11 @@ export function createToolGatewayService( signal?.throwIfAborted(); // Legacy/plugin sessions may not carry identity fields. Resolve them from // the admitted run, never from parameters. Signed approvals keep their origin. - let identity = session; + let identity = session.originatingAgentId ? { ...session, agentId: session.originatingAgentId } : session; if (session.runId && !approvedAction && !session.identityContextId) { const [run] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, session.runId), eq(heartbeatRuns.companyId, session.companyId))); - identity = await captureSessionIdentity({ ...session, responsibleUserId: run?.responsibleUserId ?? session.responsibleUserId }); + identity = await captureSessionIdentity({ ...identity, responsibleUserId: run?.responsibleUserId ?? session.responsibleUserId }); } return injectionPrevention.classify({ companyId: identity.companyId, source: "gateway", actorType: identity.actorType === "user" ? "user" : identity.agentId ? "agent" : "system", @@ -1237,7 +1241,65 @@ export function createToolGatewayService( }); const pluginToolDispatcher = options.pluginToolDispatcher; const interactions = issueThreadInteractionService(db); - const policyService = toolAccessPolicyService(db, { toolActionSigningSecret: options.toolActionSigningSecret }); + const basePolicyService = toolAccessPolicyService(db, { toolActionSigningSecret: options.toolActionSigningSecret }); + const issuerDecisionCaches = new WeakMap(); + const policyService = { + ...basePolicyService, + async decide(input: ToolAccessDecisionInput, decisionOptions: NonNullable[1]> & { requiredGatewayAction?: ToolMcpGatewayTokenAction } = {}): Promise { + const issuerId = input.runContext?.originatingAgentId; + if (!issuerId || !input.runContext?.gatewayId || input.actor.actorType !== "agent") return basePolicyService.decide(input, decisionOptions); + if (!input.runContext.gatewayTokenId) throw new ToolGatewayHttpError(403, "Delegated gateway token is no longer available", "gateway_token_run_context_invalid"); + let issuerCache: ToolAccessDecisionCache | undefined; + if (decisionOptions.cache) { + issuerCache = issuerDecisionCaches.get(decisionOptions.cache); + if (!issuerCache) { issuerCache = createToolAccessDecisionCache(); issuerDecisionCaches.set(decisionOptions.cache, issuerCache); } + } + const requiredAction = decisionOptions.requiredGatewayAction ?? (input.consumeRateLimit ? "tools/call" : undefined); + const authorityKey = `gateway-issuer:${input.companyId}:${input.runContext.gatewayTokenId}:${issuerId}:${requiredAction ?? "inspect"}`; + let authority = issuerCache?.get(authorityKey); + if (!authority) { + authority = assertGatewayIssuerAuthority(input, requiredAction); + issuerCache?.set(authorityKey, authority); + } + await authority; + if (issuerId === input.actor.agentId) { + const decision = await basePolicyService.decide(input, decisionOptions); + return { ...decision, policyExplanation: { ...decision.policyExplanation, delegatedGatewayIssuer: issuerId } }; + } + const issuerInput = { ...input, actor: { ...input.actor, agentId: issuerId } }; + const issuer = await basePolicyService.decide(issuerInput, { ...decisionOptions, cache: issuerCache }); + const target = await basePolicyService.decide(input, { ...decisionOptions, sharedRateDecision: issuer }); + const chosen = !issuer.allowed && issuer.decision !== "require_approval" ? issuer + : !target.allowed ? target : issuer.decision === "require_approval" ? issuer : target; + if (input.consumeRateLimit) await basePolicyService.writeAudit(issuerInput, issuer); + return { ...chosen, matchedPolicyIds: [...new Set([...issuer.matchedPolicyIds, ...target.matchedPolicyIds])], policyExplanation: { + ...chosen.policyExplanation, + consumedRateAdmissions: target.policyExplanation?.consumedRateAdmissions, + delegatedCallerConsumedRateAdmissions: issuer.policyExplanation?.consumedRateAdmissions, + delegatedGatewayIssuer: issuerId, + } }; + }, + }; + + async function assertGatewayIssuerAuthority(input: ToolAccessDecisionInput, requiredAction?: ToolMcpGatewayTokenAction) { + const [row] = await db.select({ token: toolMcpGatewayTokens, gateway: toolMcpGateways }).from(toolMcpGatewayTokens) + .innerJoin(toolMcpGateways, eq(toolMcpGateways.id, toolMcpGatewayTokens.gatewayId)) + .where(and(eq(toolMcpGatewayTokens.id, input.runContext!.gatewayTokenId!), eq(toolMcpGatewayTokens.companyId, input.companyId))); + const issuerId = input.runContext!.originatingAgentId!; + if (!row || row.token.subjectType !== "gateway_client" || row.token.createdByAgentId !== issuerId || !row.token.responsibleUserId + || input.actor.actorId !== issuerId || row.gateway.id !== input.runContext?.gatewayId || row.gateway.status !== "active" + || row.token.revokedAt || (row.token.expiresAt && row.token.expiresAt <= new Date()) + || (row.gateway.agentId && row.gateway.agentId !== input.actor.agentId)) { + throw new ToolGatewayHttpError(403, "Delegated gateway authority is no longer current", "gateway_token_run_context_invalid"); + } + if (requiredAction && !normalizeGatewayTokenActions(row.token.allowedActions).includes(requiredAction)) throw new ToolGatewayHttpError(403, "The gateway token no longer allows this action", "gateway_token_action_not_allowed"); + const actor = { type: "agent" as const, companyId: input.companyId, agentId: issuerId, onBehalfOfUserId: row.token.responsibleUserId }; + const resource = { type: "connection_management" as const, companyId: input.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 }; + await connectionManagementService(db).authorize(actor, "connections:gateway", resource, { mutation: false }); + await connectionManagementService(db).authorize(actor, "connections:test", resource, { mutation: false }); + } async function recordToolInvocation(input: ToolAccessDecisionInput, decision: ToolAccessDecision) { try { return await policyService.recordInvocation(input, decision); @@ -2572,7 +2634,7 @@ export function createToolGatewayService( .values({ companyId: input.session.companyId, type: "request_board_approval", - requestedByAgentId: input.session.agentId, + requestedByAgentId: input.session.originatingAgentId ?? input.session.agentId, payload: { title: `Approve high-risk tool action: ${input.tool.name}`, summary: `${input.tool.name} is classified as ${input.tool.risk} and requires formal board approval before execution.`, @@ -2792,6 +2854,8 @@ export function createToolGatewayService( idempotencyKey: input.idempotencyKey, consumeRateLimit: input.consumeRateLimit, heartbeatRunId: input.session.runId, + originatingAgentId: input.session.originatingAgentId, + gatewayTokenId: input.session.gatewayTokenId, issueId: input.session.issueId, projectId: input.session.projectId, gatewayId: input.session.gatewayId ?? null, @@ -2808,6 +2872,8 @@ export function createToolGatewayService( idempotencyKey?: string | null; consumeRateLimit?: boolean; heartbeatRunId?: string | null; + originatingAgentId?: string | null; + gatewayTokenId?: string | null; issueId?: string | null; projectId?: string | null; gatewayId?: string | null; @@ -2824,9 +2890,11 @@ export function createToolGatewayService( }, runContext: { heartbeatRunId: input.heartbeatRunId ?? null, + originatingAgentId: input.originatingAgentId ?? null, issueId: input.issueId ?? null, projectId: input.projectId ?? null, gatewayId: input.gatewayId ?? null, + gatewayTokenId: input.gatewayTokenId ?? null, }, request: { toolName: input.tool.name, @@ -3821,7 +3889,7 @@ export function createToolGatewayService( try { return (await resolveConnectionGrantSecret(db, connection, grant, ref, { consumerType: "tool_connection", consumerId: connection.id, - actorType: "system", actorId: session.agentId, + actorType: "system", actorId: session.originatingAgentId ?? session.agentId, responsibleUserId: grant.subjectUserId, issueId: session.issueId, heartbeatRunId: session.runId, })).value; } catch (error) { @@ -6792,6 +6860,7 @@ export function createToolGatewayService( revokedAt: row.revokedAt, createdByAgentId: row.createdByAgentId, createdByUserId: row.createdByUserId, + responsibleUserId: row.responsibleUserId, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -7337,6 +7406,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) @@ -7355,11 +7447,12 @@ export function createToolGatewayService( gatewayName: row.gateway.name, gatewayProfileId: row.gateway.profileId, gatewayTokenId: row.token.id || tokenId, + originatingAgentId: row.token.subjectType === "gateway_client" && row.token.responsibleUserId ? row.token.createdByAgentId : null, 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: @@ -7385,6 +7478,7 @@ export function createToolGatewayService( function isTestOriginInvocation( invocation: typeof toolInvocations.$inferSelect, ): boolean { + if (invocation.policyExplanation?.delegatedConnectionTest) return invocation.connectionId !== null && invocation.gatewayId === null; return ( invocation.actorType === "user" && invocation.runId === null && @@ -7394,6 +7488,44 @@ export function createToolGatewayService( ); } + function delegatedTestCallerSession(session: ToolGatewaySession): ToolGatewaySession | null { + const callerId = session.originatingAgentId; + return callerId && callerId !== session.agentId ? { ...session, agentId: callerId } : null; + } + + async function assertDelegatedTestCredentialAccess(session: ToolGatewaySession, tool: ToolGatewayDescriptor) { + const caller = delegatedTestCallerSession(session); + if (!caller || !tool.connectionId) return; + const [connection] = await db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, session.companyId), eq(toolConnections.id, tool.connectionId), + )); + if (!connection) throw new ToolGatewayHttpError(404, "Tool connection not found", "connection_not_found"); + // Resolve consent metadata before any vault read. Testing another agent + // cannot select that agent's dedicated credential or a different identity. + const callerGrant = await resolveConnectionGrant(caller, connection, false); + const targetGrant = await resolveConnectionGrant(session, connection, false); + if (callerGrant.id !== targetGrant.id) { + throw new ToolGatewayHttpError(403, "Delegated calls cannot use another agent's credential identity", session.gatewayTokenId ? "gateway_credential_identity_mismatch" : "test_credential_identity_mismatch"); + } + } + + async function assertCurrentDelegatedTestCaller(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown, invocationId: string) { + const caller = delegatedTestCallerSession(session); + if (!caller) return; + const current = await policyService.decide(policyInputForTool({ session: caller, tool, parameters }), { admittedInvocationId: invocationId }); + if (!current.allowed && !(session.approvedInvocationId && current.decision === "require_approval")) { + throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode); + } + await assertDelegatedTestCredentialAccess(session, tool); + } + + async function assertCurrentGatewayIssuer(session: ToolGatewaySession, tool: ToolGatewayDescriptor, parameters: unknown, invocationId: string, approved: boolean) { + if (!session.gatewayId || !session.originatingAgentId) return; + const current = await policyService.decide(policyInputForTool({ session, tool, parameters }), { admittedInvocationId: invocationId, requiredGatewayAction: "tools/call" }); + if (!current.allowed && !(approved && current.decision === "require_approval")) throw new ToolGatewayHttpError(policyErrorStatus(current), current.explanation, current.reasonCode); + await assertDelegatedTestCredentialAccess(session, tool); + } + /** * Execute an already-authorized test-tab tool call against the connected MCP * server and record the result/error onto the invocation. Shared by @@ -7414,6 +7546,7 @@ export function createToolGatewayService( matchedPolicyIds: string[]; timeoutMs?: number; signal?: AbortSignal; + managementContext?: ConnectionManagementContext; }): Promise< | { decision: "allowed"; invocationId: string; result: unknown } | { @@ -7434,10 +7567,10 @@ export function createToolGatewayService( session: args.session, companyId: args.companyId, agentId: args.agentId, - runId: null, - issueId: null, - actorType: "user", - actorId: args.userId, + runId: args.session.runId, + issueId: args.session.issueId, + actorType: args.session.actorType, + actorId: args.session.actorId ?? args.userId, action: "tool_gateway.call_allowed", details: { source: "test", @@ -7453,6 +7586,15 @@ export function createToolGatewayService( const startedAt = Date.now(); try { + await args.managementContext?.reauthorize(); + if (args.managementContext?.actor.type === "agent") { + Object.assign(args.session, args.managementContext.workContext); + await assertCurrentDelegatedTestCaller(args.session, args.tool, args.parameters, args.invocationId); + const current = await policyService.decide(policyInputForTool({ session: args.session, tool: args.tool, parameters: args.parameters }), { admittedInvocationId: args.invocationId }); + if (!current.allowed && !(args.session.approvedInvocationId && current.decision === "require_approval")) { + throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode); + } + } const executionTimeoutMs = timeoutMs(args.timeoutMs, args.tool); const connectedMcpExecution = (args.tool.providerType === "mcp_remote_http" || args.tool.providerType === "provider_rest") @@ -7489,7 +7631,7 @@ export function createToolGatewayService( value: result, direction: "result", sensitiveMode: "redact", - promptInjectionMode: "block", classifier: resultClassifier(args.session, args.tool, args.invocationId, { signal: args.signal }), + promptInjectionMode: "block", classifier: resultClassifier(args.session, args.tool, args.invocationId, { signal: args.signal, approvedAction: Boolean(args.session.approvedInvocationId) }), }); const providerResult = asRecord(resultValidation.value); if (providerResult?.error) { @@ -7529,10 +7671,10 @@ export function createToolGatewayService( session: args.session, companyId: args.companyId, agentId: args.agentId, - runId: null, - issueId: null, - actorType: "user", - actorId: args.userId, + runId: args.session.runId, + issueId: args.session.issueId, + actorType: args.session.actorType, + actorId: args.session.actorId ?? args.userId, action: "tool_gateway.call_completed", details: { source: "test", @@ -7608,10 +7750,10 @@ export function createToolGatewayService( session: args.session, companyId: args.companyId, agentId: args.agentId, - runId: null, - issueId: null, - actorType: "user", - actorId: args.userId, + runId: args.session.runId, + issueId: args.session.issueId, + actorType: args.session.actorType, + actorId: args.session.actorId ?? args.userId, action: status === 504 ? "tool_gateway.call_deferred" @@ -7658,7 +7800,11 @@ export function createToolGatewayService( ): Promise { const agentId = invocation.agentId; if (!invocation.connectionId || !agentId) return; - const userId = invocation.actorId ?? "board"; + const delegated = invocation.policyExplanation?.delegatedConnectionTest as { + agentId: string; responsibleUserId: string; runId: string | null; keyId: string | null; + resource: ConnectionManagementContext["resource"]; + } | undefined; + const userId = delegated?.responsibleUserId ?? invocation.actorId ?? "board"; const session: ToolGatewaySession = { id: "test-call", token: "test-call", @@ -7667,12 +7813,13 @@ 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), }; + let managementContext: ConnectionManagementContext | undefined; let tool: ToolGatewayDescriptor | undefined; try { tool = ( @@ -7715,8 +7862,18 @@ export function createToolGatewayService( promptInjectionMode: "ignore", })).summary; try { - await runTestToolInvocation({ + if (delegated) { + managementContext = 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], afterHumanAction: true }); + Object.assign(session, managementContext.workContext, { originatingAgentId: delegated.agentId, approvedInvocationId: invocation.id }); + const current = await policyService.decide(policyInputForTool({ session, tool, parameters }), { admittedInvocationId: invocation.id }); + if (!current.allowed && current.decision !== "require_approval") throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode); + } + const outcome = await runTestToolInvocation({ session, + managementContext, tool, parameters, invocationId: invocation.id, @@ -7729,7 +7886,8 @@ export function createToolGatewayService( }); await reflectToolActionInteractionLifecycle({ actionRequestId, - status: "executed", + status: "error" in outcome ? "failed" : "executed", + ...("error" in outcome ? { errorCode: outcome.error.reasonCode, errorMessage: outcome.error.message } : {}), }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -8017,6 +8175,16 @@ export function createToolGatewayService( session: ToolGatewaySession, identityContextId: string | undefined, ) { + if (session.gatewayTokenId && session.gatewayId) { + const [token] = await db.select({ subjectType: toolMcpGatewayTokens.subjectType, issuer: toolMcpGatewayTokens.createdByAgentId, userId: toolMcpGatewayTokens.responsibleUserId }) + .from(toolMcpGatewayTokens).where(and(eq(toolMcpGatewayTokens.id, session.gatewayTokenId), eq(toolMcpGatewayTokens.companyId, session.companyId), eq(toolMcpGatewayTokens.gatewayId, session.gatewayId))); + // Durable delegated clients retain issuer authority across approval. + // Board clients and independently run-bound heartbeat clients keep their identity. + if (token?.subjectType === "gateway_client" && (token.issuer || token.userId)) { + session.originatingAgentId = token.issuer ?? session.originatingAgentId ?? session.actorId; + session.responsibleUserId = token.userId; + } + } if (!identityContextId) return; const [origin] = await db .select() @@ -8150,7 +8318,8 @@ export function createToolGatewayService( gatewayPublicId: invocation.gatewayPublicId, gatewayTokenId: invocation.gatewayTokenId, actorType: "agent", - actorId: invocation.agentId, + actorId: invocation.actorType === "agent" ? invocation.actorId ?? invocation.agentId : invocation.agentId, + originatingAgentId: typeof invocation.policyExplanation?.delegatedGatewayIssuer === "string" ? invocation.policyExplanation.delegatedGatewayIssuer : invocation.actorType === "agent" && invocation.gatewayId && !invocation.runId && !invocation.gatewayTokenId ? invocation.actorId : null, createdAt: new Date(), expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS), }; @@ -8176,6 +8345,7 @@ export function createToolGatewayService( tool, parameters: signedPayload.arguments, }), + { admittedInvocationId: invocation.id, requiredGatewayAction: "tools/call" }, ); if ( !currentAccess.allowed && @@ -8306,6 +8476,7 @@ export function createToolGatewayService( }); try { + await assertCurrentGatewayIssuer(session, tool, parameters, invocation.id, true); const executionTimeoutMs = timeoutMs(APPROVED_EXECUTION_TIMEOUT_MS); const result = (tool.providerType === "mcp_remote_http" || tool.providerType === "provider_rest") @@ -8772,7 +8943,9 @@ export function createToolGatewayService( companyId: string; body: CreateToolMcpGateway; actor?: { agentId?: string | null; userId?: string | null }; + managementContext?: ConnectionManagementContext; }): Promise { + await input.managementContext?.reauthorize(); await assertGatewayContext({ companyId: input.companyId, profileId: input.body.profileId, @@ -8872,6 +9045,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 +9055,9 @@ export function createToolGatewayService( companyId: string; gatewayId: string; body: UpdateToolMcpGateway; + managementContext?: ConnectionManagementContext; }): Promise { + await input.managementContext?.reauthorize(); const [existing] = await db .select() .from(toolMcpGateways) @@ -8991,6 +9169,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 +9180,9 @@ export function createToolGatewayService( gatewayId: string; body: CreateToolMcpGatewayToken; actor?: { agentId?: string | null; userId?: string | null }; + managementContext?: ConnectionManagementContext; }): Promise { + await input.managementContext?.reauthorize(); const [gateway] = await db .select() .from(toolMcpGateways) @@ -9016,6 +9199,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 +9242,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 +9259,9 @@ export function createToolGatewayService( companyId: string; tokenId: string; revokedAt?: Date; + managementContext?: ConnectionManagementContext; }): Promise { + await input.managementContext?.reauthorize(); const now = input.revokedAt ?? new Date(); const [row] = await db .update(toolMcpGatewayTokens) @@ -9083,6 +9279,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,17 +9620,20 @@ 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", token: "test-call", companyId: input.companyId, agentId: input.agentId, - runId: null, - issueId: null, - projectId: null, - actorType: "user", - actorId: input.userId, + runId: input.managementContext?.workContext.runId ?? null, + issueId: input.managementContext?.workContext.issueId ?? null, + projectId: input.managementContext?.workContext.projectId ?? null, + originatingAgentId: actingAgentId, + actorType: actingAgentId ? "agent" : "user", + actorId: actingAgentId ?? input.userId, responsibleUserId: input.userId, createdAt: new Date(), expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS), @@ -9469,23 +9671,46 @@ export function createToolGatewayService( sensitiveMode: "redact", promptInjectionMode: "ignore", }); - const decisionInput = policyInputForAgentTool({ - companyId: input.companyId, - agentId: input.agentId, - actorType: "user", - actorId: input.userId, + const decisionInput = policyInputForTool({ + session, tool, parameters: requestedParameters, idempotencyKey: `test-call:${randomUUID()}`, consumeRateLimit: true, }); - const accessDecision = await policyService.decide(decisionInput); + await assertDelegatedTestCredentialAccess(session, tool); + const callerSession = delegatedTestCallerSession(session); + const callerDecisionInput = callerSession ? policyInputForTool({ session: callerSession, tool, parameters: requestedParameters, consumeRateLimit: true }) : null; + const callerDecision = callerDecisionInput ? await policyService.decide(callerDecisionInput) : null; + const targetDecision = await policyService.decide(decisionInput, { sharedRateDecision: callerDecision ?? undefined }); + // Both principals' action controls apply. Either denial wins; either + // Ask first decision creates the ordinary human approval request. + const chosenDecision = callerDecision && !callerDecision.allowed && callerDecision.decision !== "require_approval" ? callerDecision + : !targetDecision.allowed ? targetDecision + : callerDecision?.decision === "require_approval" ? callerDecision : targetDecision; + const accessDecision = { ...chosenDecision, policyExplanation: { + ...chosenDecision.policyExplanation, + consumedRateAdmissions: targetDecision.policyExplanation?.consumedRateAdmissions, + delegatedCallerConsumedRateAdmissions: callerDecision?.policyExplanation?.consumedRateAdmissions, + } }; + if (callerDecisionInput && callerDecision) await policyService.writeAudit(callerDecisionInput, callerDecision); + if (accessDecision.allowed || accessDecision.decision === "require_approval") await assertDelegatedTestCredentialAccess(session, tool); const recorded = await recordToolInvocation( decisionInput, accessDecision, ); 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) { @@ -9591,10 +9816,10 @@ export function createToolGatewayService( session, companyId: input.companyId, agentId: input.agentId, - runId: null, - issueId: null, - actorType: "user", - actorId: input.userId, + runId: session.runId, + issueId: session.issueId, + actorType: actingAgentId ? "agent" : "user", + actorId: actingAgentId ?? input.userId, action: "tool_gateway.approval_requested", details: { source: "test", @@ -9623,10 +9848,10 @@ export function createToolGatewayService( session, companyId: input.companyId, agentId: input.agentId, - runId: null, - issueId: null, - actorType: "user", - actorId: input.userId, + runId: session.runId, + issueId: session.issueId, + actorType: actingAgentId ? "agent" : "user", + actorId: actingAgentId ?? input.userId, action: "tool_gateway.call_denied", details: { source: "test", @@ -9650,8 +9875,10 @@ export function createToolGatewayService( }; } + await input.managementContext?.reauthorize(); return runTestToolInvocation({ session, + managementContext: input.managementContext, tool, parameters: requestedParameters, invocationId, @@ -9675,6 +9902,7 @@ export function createToolGatewayService( companyId: string; connectionId: string; actionRequestId: string; + actor?: AuthorizationActor; }): Promise { const [actionRequest] = await db .select() @@ -9710,6 +9938,23 @@ export function createToolGatewayService( "action_request_not_found", ); } + if (input.actor?.type === "agent" && invocation.actorId !== input.actor.agentId) { + throw new ToolGatewayHttpError(403, "Test belongs to another principal", "test_principal_access_denied"); + } + if (invocation.issueId) { + if (!input.actor) throw new ToolGatewayHttpError(403, "Task access is required", "test_task_access_denied"); + const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, invocation.issueId))); + const decision = issue ? await authorizationService(db).decide({ actor: input.actor, action: "issue:read", resource: { + type: "issue", companyId: input.companyId, issueId: issue.id, projectId: issue.projectId, + parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, + } }) : null; + if (!issue || !decision?.allowed || (input.actor.type === "agent" && ( + !(await canActorReadIssuePrivacy(db, input.actor, issue)) || !input.actor.onBehalfOfUserId + || !(await canActorReadIssuePrivacy(db, { type: "board", userId: input.actor.onBehalfOfUserId }, issue)) + ))) { + throw new ToolGatewayHttpError(403, "Task is unavailable to this principal", "test_task_access_denied"); + } + } return buildTestCallStatus(actionRequest, invocation); }, @@ -10035,6 +10280,12 @@ export function createToolGatewayService( token: "", companyId: input.companyId, agentId: invocation.agentId, + actorType: "agent", + actorId: invocation.actorId, + originatingAgentId: typeof invocation.policyExplanation?.delegatedGatewayIssuer === "string" ? invocation.policyExplanation.delegatedGatewayIssuer : invocation.actorType === "agent" && invocation.gatewayId && !invocation.runId && !invocation.gatewayTokenId ? invocation.actorId : null, + gatewayId: invocation.gatewayId, + gatewayPublicId: invocation.gatewayPublicId, + gatewayTokenId: invocation.gatewayTokenId, runId: invocation.runId, issueId: issue.id, projectId: issue.projectId, @@ -10064,6 +10315,7 @@ export function createToolGatewayService( tool, parameters: signedPayload.arguments, }), + { admittedInvocationId: invocation.id, requiredGatewayAction: "tools/call" }, ); if (!access.allowed && access.decision !== "require_approval") throw new ToolGatewayHttpError( @@ -10450,12 +10702,13 @@ export function createToolGatewayService( "invocation_not_found", ); } + const requestingActor = policyInputForTool({ session, tool }).actor; if ( actionRequest.issueId !== session.issueId || storedInvocation.issueId !== session.issueId || storedInvocation.agentId !== session.agentId || storedInvocation.runId !== session.runId || - actionRequest.requestedByAgentId !== session.agentId + actionRequest.requestedByAgentId !== (requestingActor.actorType === "agent" ? requestingActor.actorId : null) ) { throw new ToolGatewayHttpError( 403, @@ -10951,6 +11204,7 @@ export function createToolGatewayService( }); try { + await assertCurrentGatewayIssuer(session, tool, effectiveParameters, invocationId, !!input.approvedActionRequestId); const executionTimeoutMs = timeoutMs(input.timeoutMs, tool); if ( tool.providerType === "paperclip_plugin" && @@ -11633,14 +11887,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( @@ -11658,14 +11917,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(