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

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-27 08:20:24 -05:00
1 parent 0cb3fc7db5
commit 13709b4c7a
99 files changed
+5006 -1089

No files matched your search

@@ -0,0 +1 @@
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
@@ -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",
+7
View File
@@ -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
}
]
}
+2
View File
@@ -75,6 +75,8 @@ export const chatGitHubRegistrations = pgTable(
manifestClaimId?: string;
identityLeaseId?: string;
identityLeaseExpiresAt?: string;
browserSessionId?: string;
identitySessionId?: string;
identityStateHash?: string;
identityExpiresAt?: string;
identityRedemptionId?: string;
+2
View File
@@ -564,6 +564,8 @@ export const toolMcpGatewayTokens = pgTable(
subjectType: text("subject_type").$type<ToolMcpGatewayTokenSubjectType>().notNull().default("gateway_client"),
subjectId: text("subject_id"),
clientLabel: text("client_label").notNull().default(""),
// Null distinguishes board/legacy clients from delegated agent clients.
responsibleUserId: text("responsible_user_id"),
ownerNote: text("owner_note").notNull().default(""),
allowedActions: jsonb("allowed_actions").$type<ToolMcpGatewayTokenAction[]>().notNull().default(["tools/list", "tools/call"]),
expiresAt: timestamp("expires_at", { withTimezone: true }),
+2 -2
View File
@@ -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": {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:1412842a36a7211d84b6b98a3d1f69e3388bc4814e5825da890a774ae0e3460e",
"sha256:22e31f95ef972bcead98a4a707c9f33433558fd111e390e027a2bbb4346e112a",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -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",
@@ -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`.";
@@ -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 () => {
@@ -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": {
@@ -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<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
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],
};
@@ -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"
}
}
@@ -3,13 +3,13 @@ import { CONNECTION_INTENT_AGENT_GUIDANCE, CONNECTION_REQUEST_TOOL_DESCRIPTION,
describe("connection result guidance", () => {
it("delegates route decisions to trusted tool output instead of a provider decision tree", () => {
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("follow the returned `instruction`");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded user choices");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded declines and provider choices");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/Composio|Arcade|Executor|Zapier/);
expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/https?:\/\/|bearer/);
});
it("retains waiting, credential, and arbitrary URL boundaries", () => {
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("yield without retrying or polling");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never invent access or ask for credentials in comments");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never ask for credentials in comments");
expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("arbitrary MCP URLs");
});
it("describes exact questions and saved selection proof", () => {
@@ -8,13 +8,15 @@
export const CONNECTION_INTENT_AGENT_GUIDANCE = [
"Connection tools:",
"- When the user asks to connect a service, call `connections_search` before any service tool, even if already installed. Also search when a task needs a service and usable access is uncertain. Search by its name or capability and follow the returned `instruction`.",
"- Use the returned service identifiers and connection tools for setup. Respect recorded user choices; never invent access or ask for credentials in comments.",
"- If installed tools are Off or the connection is not enabled for this agent, call `connection_request` with the saved connectionId and required toolNames. It creates a scoped human access card; never substitute a generic permission question or change permissions yourself.",
"- Distinguish tool, chat/channel, and AI account purposes. Use `search_api` and `call_api` for authorized connection configuration requested by the user. Standard agents can manage connections under their responsible user's authority; inspect the returned capabilities and respect explicit restrictions. Reuse saved accounts and setup drafts.",
"- Suggest a relevant connection when it would help the task and usable access is missing. Respect recorded declines and provider choices. Ask only for missing decisions, consent, credentials, or authority; never ask for credentials in comments. Hand off the exact required human action at the saved setup link, then inspect status and continue through the API.",
"- If a tool call is blocked by Off/Ask first or missing installation access, use `connection_request` with the saved connectionId and required toolNames. Do not silently relax policy to work around a failed call. You may perform an access-policy change explicitly requested by the user when authorized; never approve your own governed action or change your own principal permissions.",
"- Report saved, authenticated, usable, and verified as separate states. A saved connection is not proof of successful authentication or a verified provider action.",
"- When waiting for user action, finish independent work, then yield without retrying or polling. On continuation, follow the recorded outcome and use the installed connection.",
"- Do not use connection tools for arbitrary MCP URLs or unrelated work.",
].join("\n");
export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or a natural-language description (up to 4000 characters). Results tolerate extra words, split names, and small typos. Choose the relevant match using its description and method purpose. Follow the returned instruction to present an inline setup card with connection_request or share the method's setupPath. Also discovers verified external aggregator apps. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only.";
export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or natural-language description (up to 4000 characters). Choose the relevant method and inspect its management capabilities. For user-requested configuration, discover and call the existing REST operation with search_api/call_api; reuse saved drafts and hand off only required human authentication or consent. Use connection_request for missing access or required user input. Also discovers verified external aggregator apps. Use first when asked to connect a service or usable access is uncertain; respect the returned instruction, recorded declines, and exact providerQuestion. Do not use for arbitrary MCP URLs. Search is read-only.";
export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search. For an existing connection with missing agent access, pass connectionId and the exact required toolNames to create an embedded Grant access card; only the human can approve it. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments.";
+1
View File
@@ -70,6 +70,7 @@ export type {
ConnectionSearchResultItem,
ConnectionsSearchResult,
ConnectionRequestResult,
ConnectionRequiredUserAction,
ConnectionIntentSetupOptions,
ConnectionIntentSetupConnection,
CompleteConnectionIntentInput,
@@ -3,6 +3,17 @@ import type { ToolConnection } from "./tool-access.js";
import type { AskUserQuestionsQuestion, PaperclipQuestionSetPayload } from "./issue.js";
import type { RemoteMcpConnectorId } from "../remote-mcp-connectors.js";
/** A saved setup's next human step. This is not an authorization grant. */
export interface ConnectionRequiredUserAction {
kind: "authenticate" | "consent" | "enter_credentials" | "confirm_identity";
userId: string;
reason: string;
resource: { type: "connection" | "chat_endpoint" | "ai_login"; id: string; companyId: string };
setupPath: string;
expiresAt?: string;
continuation?: string;
}
export type ConnectionAvailabilityState =
| "ready"
| "needs_user_action"
@@ -32,6 +43,15 @@ export interface ConnectionSearchResultItem {
purpose?: "tool" | "channel" | "ai";
/** Company-scoped setup destination for methods with a separate setup flow. */
setupPath?: string;
management?: {
allowed: boolean;
reason: string;
operation: string;
canUse: boolean;
requiresApproval?: boolean;
requiresHumanAuthentication: boolean;
nextApiOperation: string;
};
}>;
state: ConnectionAvailabilityState;
connectionId: string | null;
@@ -62,6 +82,7 @@ export interface ConnectionRequestResult {
export type ConnectionIntentSetupConnection = Pick<ToolConnection, "id" | "applicationId" | "name" | "status" | "enabled">;
export interface ConnectionIntentSetupOptions {
chatSetup?: { path: string; endpointId: string; connectionId: string };
/** Resume this request's saved AgentMail account after a partial setup. */
emailSetup?: { credentialConnectionId: string | null; readyConnectionId: string | null };
canGrantAccess?: boolean;
+1
View File
@@ -12,6 +12,7 @@ export type {
ConnectionSearchResultItem,
ConnectionsSearchResult,
ConnectionRequestResult,
ConnectionRequiredUserAction,
ConnectionIntentSetupOptions,
ConnectionIntentSetupConnection,
CompleteConnectionIntentInput,
+2
View File
@@ -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<string, string | null>; requireAll: boolean } };
version: 1;
/** Server-authored consent to a fixed set of tools on an existing connection. */
accessRequest?: {
+7 -1
View File
@@ -302,6 +302,8 @@ export interface ConnectionGrantCapabilities {
* not "render disabled".
*/
export interface ToolConnectionCapabilities {
management?: { allowed: boolean; reason: string; operation: string };
usage?: { allowed: boolean; reason: string; operation: string; requiresApproval?: boolean; allowedCount?: number; askFirstCount?: number };
canConfigure: boolean;
canCreateOrganizationGrant: boolean;
canSetCompanyInstall: boolean;
@@ -745,6 +747,8 @@ export interface ToolMcpGatewayToken {
revokedAt: Date | string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
/** Agent client delegation. Null with an issuing agent identifies an unbound legacy client. */
responsibleUserId?: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
@@ -1234,7 +1238,8 @@ export interface ToolOAuthStartResult {
connectionId: string;
provider: string;
authorizationUrl: string;
expiresAt: string;
/** Null until the addressed human starts provider authorization from a saved setup. */
expiresAt: string | null;
/**
* Opaque Paperclip Cloud authorization handoff. The board submits this only
* to its fixed same-origin Cloud endpoint; it is never treated as a URL.
@@ -1574,6 +1579,7 @@ export interface ToolAccessDecisionInput {
};
runContext?: {
heartbeatRunId?: string | null;
originatingAgentId?: string | null;
issueId?: string | null;
projectId?: string | null;
routineId?: string | null;
+5
View File
@@ -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),
@@ -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());
@@ -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");
@@ -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
File diff suppressed because it is too large. Load diff
@@ -300,10 +300,12 @@ describe("company decision service", () => {
const url = `/api/companies/${f.companyId}/decision-model`;
expect((await request(app).get(url)).body.canManage).toBe(true);
expect((await request(app).get(`/api/companies/${other.companyId}/decision-model`)).status).toBe(403);
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice")));
actor = { type: "board", userId: "alice", source: "session", companyIds: [f.companyId], memberships: [{ companyId: f.companyId, status: "active", membershipRole: "viewer" }] };
expect((await request(app).get(url)).body).toEqual({ canManage: false, settings: null, choices: [] });
expect((await request(app).put(url).send(f.config)).status).toBe(403);
expect((await request(app).post(`${url}/test`).send({ actor: { type: "system" } })).status).toBe(403);
await db.update(companyMemberships).set({ membershipRole: "owner" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "alice")));
actor = f.context.actor;
await f.service.configure(f.companyId, "alice", { ...f.config, enabled: false });
expect((await request(app).post(`${url}/test`).send({ state: "untrusted", actor: { type: "system" }, model: "untrusted" })).body).toEqual({ status: "unavailable", reason: "disabled" });
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import type { Db } from "@paperclipai/db";
import { companyMemberships, instanceUserRoles, principalPermissionGrants, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { forbidden } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
@@ -18,7 +18,16 @@ function app(actor = { type: "board", source: "local_implicit", userId: "board"
const instance = express();
instance.use(express.json());
instance.use((req, _res, next) => { req.actor = actor; next(); });
instance.use("/api", emailRoutes({} as Db, {} as EmailChannelService));
// Provider calls remain stubbed, while the shared guard resolves real-shaped
// resource and membership rows before those calls.
const db = { select: () => ({ from: (table: unknown) => {
const rows = table === toolConnections ? [{ id: connectionId, companyId, createdByUserId: "board", connectionPurpose: "tool", credentialPolicy: "shared", config: { provider: "agentmail" }, transportConfig: {} }]
: table === companyMemberships ? [{ companyId, principalType: "user", principalId: "member", status: "active", membershipRole: "member" }]
: [];
expect([toolConnections, toolConnectionInstalls, companyMemberships, instanceUserRoles, principalPermissionGrants]).toContain(table);
return { where: () => Object.assign(Promise.resolve(rows), { limit: async () => rows }) };
} }) } as unknown as Db;
instance.use("/api", emailRoutes(db, {} as EmailChannelService));
instance.use(errorHandler);
return instance;
}
@@ -59,7 +68,7 @@ describe("AgentMail address checks", () => {
expect(mocks.credential).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("does not allow agents to check addresses", async () => {
it("rejects agents without a responsible user", async () => {
await request(app({ type: "agent", agentId: connectionId, companyId })).post(path).send(input).expect(403);
expect(fetch).not.toHaveBeenCalled();
});
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import type { Db } from "@paperclipai/db";
import { agents, type Db } from "@paperclipai/db";
import { describe, expect, it, vi } from "vitest";
import { forbidden } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
@@ -26,7 +26,13 @@ function app(setup: () => Promise<unknown>) {
req.actor = { type: "board", source: "local_implicit", userId: "board" };
next();
});
instance.use("/api", emailRoutes({} as Db, { setup, inspect: setup } as unknown as EmailChannelService));
// This unit suite exercises provider error translation. The shared guard
// also resolves the setup's target agent before calling the stubbed service.
const db = { select: () => ({ from: (table: unknown) => {
expect(table).toBe(agents);
return { where: async () => [{ id: agentId, companyId }] };
} }) } as unknown as Db;
instance.use("/api", emailRoutes(db, { setup, inspect: setup } as unknown as EmailChannelService));
instance.use(errorHandler);
return instance;
}
@@ -1,10 +1,13 @@
import { hashPassword } from "better-auth/crypto";
import { createBetterAuthInstance, createBetterAuthHandler, resolveBetterAuthSession } from "../../auth/better-auth.js";
import type { Config } from "../../config.js";
import { createHash, randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, sql } from "drizzle-orm";
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
import { agents, authUsers, authAccounts, companies, companyMemberships, createDb, 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<string, string>();
const app = await createApp(db, {
uiMode: "none", serverPort: 0, storageService: storage,
deploymentMode: options.deploymentMode ?? "authenticated", deploymentExposure: "private",
allowedHostnames: ["127.0.0.1"], bindHost: "127.0.0.1", authReady: true,
authPublicBaseUrl: options.authPublicBaseUrl, chatWebhookPublicBaseUrl: options.authPublicBaseUrl,
...(auth ? { betterAuthHandler: createBetterAuthHandler(auth), resolveSession: (req: import("express").Request) => resolveBetterAuthSession(auth, req) } : {}),
companyDeletionEnabled: false, instanceId: `eval-${randomUUID()}`,
localPluginDir: join(root, "plugins"), managedPluginAutoInstall: [],
decisionServiceOptions: { wakeOriginAgent: async () => undefined },
@@ -44,6 +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<string, unknown>; disableWakeOnDemand?: boolean } = {}) {
if (options.connectionScenario !== undefined && !CONNECTION_SCENARIOS.includes(options.connectionScenario)) throw new Error(`Unknown connection eval scenario: ${String(options.connectionScenario)}`);
// This DB is created inside this helper, never supplied by a caller. Paid
+32 -13
View File
@@ -407,13 +407,13 @@ describe("openapi routes", () => {
expect(operation.responses["503"]).toBeDefined();
});
it("documents board-only pool management with revision-checked deletion", () => {
it("documents delegated pool management with revision-checked deletion", () => {
const { spec } = loadSpecRoutes();
const pools = spec.paths["/api/companies/{companyId}/ai-connection-pools"];
const remove = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}"].delete;
const inspection = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection"].get;
for (const operation of [pools.get, pools.post, remove, inspection]) {
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }, { AgentBearerAuth: [] }]);
}
expect(remove.requestBody.required).toBe(true);
expect(remove.requestBody.content["application/json"].schema).toMatchObject({
@@ -616,7 +616,7 @@ describe("openapi routes", () => {
).not.toContain("sessionToken");
});
it("publishes the complete board contract for chat channels", () => {
it("publishes delegated chat configuration and human-only confirmation boundaries", () => {
const { spec } = loadSpecRoutes();
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
const operations = [
@@ -678,6 +678,13 @@ describe("openapi routes", () => {
],
] as const;
const humanOnly = new Set([
"/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
"/api/chat-endpoints/{endpointId}/github/identity", "/api/chat-identity-links/preview", "/api/chat-identity-links/confirm",
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve", "/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve",
]);
for (const [method, routePath] of operations) {
const operation = spec.paths[routePath]?.[method];
expect(
@@ -686,10 +693,10 @@ describe("openapi routes", () => {
).toBeDefined();
expect(
operation.security,
`${method.toUpperCase()} ${routePath} is board-only`,
).toEqual(boardSecurity);
`${method.toUpperCase()} ${routePath} retains its actor boundary`,
).toEqual(humanOnly.has(routePath) ? boardSecurity : [...boardSecurity, { AgentBearerAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({
actor: "board",
actor: humanOnly.has(routePath) ? "board" : "board_or_agent",
});
expect(operation.tags).toContain("chat-channels");
}
@@ -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();
@@ -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();
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 });
@@ -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))
+10 -8
View File
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 () => {
+3 -3
View File
@@ -704,16 +704,16 @@ export async function createApp(
app.get("/api/chat-github/manifest/callback", async (req, res) => {
res.set("Cache-Control", "no-store");
res.set("Referrer-Policy", "no-referrer");
const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? ""));
const redirect = await chatChannels.githubWizard.directCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor);
res.redirect(303, redirect);
});
app.get("/api/chat-github/cloud/callback", async (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined));
res.redirect(303, await chatChannels.githubWizard.cloudCallback(String(req.query.state ?? ""), String(req.query.registration ?? ""), typeof req.query.claim === "string" ? req.query.claim : undefined, req.actor));
});
app.get("/api/chat-github/identity/callback", async (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? "")));
res.redirect(303, await chatChannels.githubWizard.identityCallback(String(req.query.state ?? ""), String(req.query.code ?? ""), req.actor));
});
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
const bundledCatalogRoot =
+49 -43
View File
@@ -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<string> = new Set(KNOWN_INSTRUCTIONS_BUNDLE_KEYS);
const router = Router();
const managementGuard = connectionManagementGuard(db, "ai");
const svc = agentService(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const svcLifecycle = createAgentLifecycle(db, { cancelWorkForScope: scope => heartbeat.cancelBudgetScopeWork(scope) });
const access = accessService(db);
@@ -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<string> {
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<string, { id: string; label: string }>();
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<string, unknown>;
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
@@ -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;
+86 -70
View File
@@ -1,3 +1,5 @@
import { connectionManagementGuard, connectionRequestActor, connectionRequestContext, connectionRequestUserId, aiLoginUserAction, filterConnectionManagementRows } from "./connection-management-authz.js";
import { connectionManagementService } from "../services/connection-management.js";
import { validateAiApiKey } from "../services/ai-api-key-test.js";
export { validateAiApiKey } from "../services/ai-api-key-test.js";
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
@@ -26,7 +28,7 @@ import {
type AiConnectionList,
aiConnectionPoolConfigSchema,
} from "@paperclipai/shared";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { accessService } from "../services/access.js";
import { logActivity } from "../services/activity-log.js";
@@ -44,11 +46,11 @@ export function responsibleUserForAiRequest(req: Request): string | null {
}
export async function canManageAiConnections(db: Db, req: Request, companyId: string): Promise<boolean> {
const membership = req.actor.memberships?.find((m) => m.companyId === companyId && m.status === "active");
if (membership?.membershipRole === "viewer") return false;
return req.actor.source === "local_implicit" || Boolean(req.actor.isInstanceAdmin)
|| membership?.membershipRole === "owner" || membership?.membershipRole === "admin"
|| await accessService(db).hasPermission(companyId, "user", getActorInfo(req).actorId, "tools:manage_connections");
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<boolean> {
if (req.actor.type !== "board" || binding.mode !== "shared") return false;
if (binding.mode !== "shared" || (req.actor.type === "agent" && (!req.actor.agentId || !req.actor.companyId))) return false;
assertCompanyAccess(req, companyId);
const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active");
if (member?.membershipRole === "viewer") return false;
const userId = getActorInfo(req).actorId;
const [connection] = await db.select({ creator: toolConnections.createdByUserId })
.from(toolConnections).where(and(eq(toolConnections.companyId, companyId),
eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai")));
if (!connection) return false;
return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true ||
connection.creator === userId || await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections");
if (req.actor.type === "board") {
const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active");
if (member?.membershipRole === "viewer") return false;
const userId = getActorInfo(req).actorId;
const [connection] = await db.select({ creator: toolConnections.createdByUserId }).from(toolConnections).where(and(
eq(toolConnections.companyId, companyId), eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai"),
));
return Boolean(connection && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin || connection.creator === userId ||
await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections")));
}
const resource = { type: "connection_management" as const, companyId, connectionId: binding.connectionId,
purpose: "ai" as const, operation: "ai_binding.install" };
const management = connectionManagementService(db);
return (await management.capability(req.actor, "connections:configure", resource)).allowed &&
(await management.capability(req.actor, "connections:profiles", resource)).allowed;
}
@@ -143,53 +151,49 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
if (!supportsLocalAiLogin(options)) throw unprocessable("Server-host subscription sign-in is unavailable on this hosted instance. Choose a supported sign-in environment or use an API key.");
}
const router = Router();
const managementGuard = connectionManagementGuard(db, "ai");
const service = aiConnectionService(db);
const localLogin = localAiLoginService(db);
const pools = aiConnectionRouterService(db);
router.get("/companies/:companyId/ai-connection-pools", async (req, res) => {
router.get("/companies/:companyId/ai-connection-pools", managementGuard("connections:read", "GET /companies/:companyId/ai-connection-pools", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req); assertCompanyAccess(req, companyId);
assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
res.setHeader("Cache-Control", "no-store");
res.json(await pools.list(companyId));
res.json(await filterConnectionManagementRows(db, req, "connections:read", await pools.list(companyId), pool => ({ connectionId: pool.id })));
});
router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", async (req, res) => {
router.get("/companies/:companyId/ai-connection-pools/:poolId/inspection", managementGuard("connections:read", "GET /companies/:companyId/ai-connection-pools/:poolId/inspection", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req); assertCompanyAccess(req, companyId);
assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
res.setHeader("Cache-Control", "no-store");
res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), getActorInfo(req).actorId));
res.json(await pools.inspect(companyId, z.string().uuid().parse(req.params.poolId), responsibleUserForAiRequest(req)!));
});
router.post("/companies/:companyId/ai-connection-pools", async (req, res) => {
router.post("/companies/:companyId/ai-connection-pools", managementGuard("connections:configure", "POST /companies/:companyId/ai-connection-pools", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req); assertCompanyAccess(req, companyId);
assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
const body = z.object({ pluginKey: z.string().min(1).max(160), id: z.string().uuid().optional(), expectedRevision: z.number().int().positive().optional(), config: aiConnectionPoolConfigSchema }).strict().parse(req.body);
res.json(await pools.save(body.pluginKey, { ...body, companyId }, getActorInfo(req).actorId));
res.json(await pools.save(body.pluginKey, { ...body, companyId }, responsibleUserForAiRequest(req)!, connectionRequestContext(req)));
});
router.delete("/companies/:companyId/ai-connection-pools/:poolId", async (req, res) => {
router.delete("/companies/:companyId/ai-connection-pools/:poolId", managementGuard("connections:configure", "DELETE /companies/:companyId/ai-connection-pools/:poolId", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req); assertCompanyAccess(req, companyId);
assertBoardOrAgent(req); assertCompanyAccess(req, companyId);
if (!(await canManageAiConnections(db, req, companyId))) throw forbidden("Manage connections permission is required");
const poolId = z.string().uuid().parse(req.params.poolId);
const { expectedRevision } = z.object({ expectedRevision: z.number().int().positive() }).strict().parse(req.body);
res.json(await pools.remove(companyId, poolId, expectedRevision, getActorInfo(req).actorId));
res.json(await pools.remove(companyId, poolId, expectedRevision, responsibleUserForAiRequest(req)!, connectionRequestContext(req)));
});
function assertLocalOperator(req: Request) {
assertBoard(req);
assertCompanyAccess(req, req.params.companyId as string);
if (req.actor.source !== "local_implicit")
throw forbidden("Only the local operator can connect this machine's CLI account.");
}
router.post("/companies/:companyId/ai-connections/local/attempts", validate(localAiLoginStartSchema), async (req, res) => {
router.post("/companies/:companyId/ai-connections/local/attempts", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local/attempts", { personal: true }), validate(localAiLoginStartSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const { restart, ...intent } = localAiLoginStartSchema.parse(req.body);
assertLocalLoginAvailable();
const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent);
res.setHeader("Cache-Control", "no-store");
res.status(201).json(await localLogin.start(companyId, userId, intent, restart));
const attempt = await localLogin.start(companyId, userId, intent, restart, connectionRequestContext(req));
res.status(201).json({ ...attempt, ...await aiLoginUserAction(db, req, companyId, attempt.sessionId) });
});
router.post("/companies/:companyId/ai-connections/local/check", validate(localAiConnectionSchema), async (req, res) => {
router.post("/companies/:companyId/ai-connections/local/check", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local/check", { personal: true }), validate(localAiConnectionSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const { localSessionId, ...intent } = localAiConnectionSchema.parse(req.body);
assertLocalLoginAvailable();
@@ -197,11 +201,11 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
res.setHeader("Cache-Control", "no-store");
res.json(await localLogin.check(companyId, userId, intent, localSessionId));
});
router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", async (req, res) => {
assertBoard(req);
router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", managementGuard("connections:configure", "DELETE /companies/:companyId/ai-connections/local/attempts/:sessionId", { personal: true }), async (req, res) => {
assertBoardOrAgent(req);
assertCompanyAccess(req, req.params.companyId as string);
const id = z.string().uuid().parse(req.params.sessionId);
await localLogin.cancel(req.params.companyId as string, getActorInfo(req).actorId, id);
await localLogin.cancel(req.params.companyId as string, responsibleUserForAiRequest(req)!, id, connectionRequestContext(req));
res.json({ ok: true });
});
router.post("/companies/:companyId/ai-connections/local/attempts/:sessionId/code", validate(z.object({ browserCode: browserCodeSchema })), async (req, res) => {
@@ -214,44 +218,44 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
res.setHeader("Cache-Control", "no-store");
res.json({ ok: true });
});
router.get("/companies/:companyId/ai-connections", async (req, res) => {
router.get("/companies/:companyId/ai-connections", managementGuard("connections:read", "GET /companies/:companyId/ai-connections", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const currentUserId = getActorInfo(req).actorId;
const currentUserId = responsibleUserForAiRequest(req)!;
res.setHeader("Cache-Control", "no-store");
const agentId = req.query.agentId;
if (agentId !== undefined && !z.string().uuid().safeParse(agentId).success)
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<typeof supportsLo
eq(toolConnections.connectionPurpose, "ai"),
),
);
if (!connection || !(await service.list(companyId, getActorInfo(req).actorId)).some(account => account.id === connection.id))
if (!connection || !(await service.list(companyId, responsibleUserForAiRequest(req)!)).some(account => account.id === connection.id))
throw notFound("AI connection not found");
res.setHeader("Cache-Control", "no-store");
res.json(
@@ -289,7 +293,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
},
);
router.post(
"/companies/:companyId/ai-connections",
"/companies/:companyId/ai-connections", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections", { personal: true }),
validate(createAiConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -316,12 +320,13 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
input.apiKey ?? "",
undefined,
attemptStartedAt,
connectionRequestContext(req),
);
res.status(201).json(result);
},
);
router.post(
"/companies/:companyId/ai-connections/local",
"/companies/:companyId/ai-connections/local", managementGuard("connections:configure", "POST /companies/:companyId/ai-connections/local", { personal: true }),
validate(localAiConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -331,18 +336,18 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
if (!localSessionId) throw unprocessable(input.provider === "xai"
? "Start local sign-in for this connection before connecting."
: "Start browser sign-in for this connection before connecting.");
res.status(201).json(await localLogin.complete(companyId, userId, localSessionId, input));
res.status(201).json(await localLogin.complete(companyId, userId, localSessionId, input, connectionRequestContext(req)));
},
);
router.put(
"/companies/:companyId/ai-connections/default",
"/companies/:companyId/ai-connections/default", managementGuard("connections:configure", "PUT /companies/:companyId/ai-connections/default", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const userId = getActorInfo(req).actorId;
const userId = responsibleUserForAiRequest(req)!;
if (
req.actor.memberships?.some(
(req.actor.type === "agent" ? req.actor.onBehalfOfMemberships : req.actor.memberships)?.some(
(m) => m.companyId === companyId && m.membershipRole === "viewer",
)
)
@@ -352,8 +357,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
await service.setDefault(companyId, userId, req.body.grantId);
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
...connectionRequestActor(req),
action: "ai_connection.default_changed",
entityType: "connection_grant",
entityId: req.body.grantId,
@@ -361,11 +365,23 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
res.json({ ok: true });
},
);
router.get("/companies/:companyId/ai-connections/login/:sessionId/setup", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/login/:sessionId/setup", { personal: true }), async (req, res) => {
const [session] = await db.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.companyId, req.params.companyId as string),
eq(adapterAuthSessions.publicSessionId, req.params.sessionId as string),
eq(adapterAuthSessions.startedByUserId, responsibleUserForAiRequest(req)!),
));
if (!session?.aiConnection || !session.expiresAt || session.expiresAt.getTime() <= Date.now() ||
["cancelled", "failed", "timed_out"].includes(session.status)) throw notFound("Saved sign-in is no longer available");
res.setHeader("Cache-Control", "no-store");
res.json({ sessionId: session.publicSessionId, intent: session.aiConnection, environmentId: session.environmentId,
status: session.status, connectionId: session.connectionId, grantId: session.connectionGrantId });
});
router.get(
"/companies/:companyId/ai-connections/login/:sessionId",
"/companies/:companyId/ai-connections/login/:sessionId", managementGuard("connections:read", "GET /companies/:companyId/ai-connections/login/:sessionId", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
const [session] = await db
.select({
@@ -376,7 +392,7 @@ export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLo
.where(
and(
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, getActorInfo(req).actorId),
eq(adapterAuthSessions.startedByUserId, responsibleUserForAiRequest(req)!),
eq(
adapterAuthSessions.publicSessionId,
req.params.sessionId as string,
+156 -88
View File
@@ -1,3 +1,8 @@
import { connectionManagementService } from "../services/connection-management.js";
import { chatCredentialRotationId, connectionIntentService, savedChatSetupPath } from "../services/connection-intents.js";
import { companies } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { requiredConnectionUserAction, filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared";
import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js";
import {
@@ -25,6 +30,7 @@ import {
resolveChatPublicationSchema,
updateChatEndpointSchema,
type ChatProvider,
type ConnectionRequiredUserAction,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import {
@@ -41,12 +47,15 @@ import {
} from "../services/invite-rate-limit.js";
import {
assertBoard,
assertBoardOrAgent,
assertCompanyAccess,
getAccessibleResource,
getActorInfo,
} from "./authz.js";
import {
badRequest,
conflict,
notFound,
forbidden,
HttpError,
tooManyRequests,
@@ -68,8 +77,7 @@ function endpointId(req: ExpressRequest): string {
}
function actorUserId(req: ExpressRequest): string | null {
const actor = getActorInfo(req);
return actor.actorType === "user" ? actor.actorId : null;
return connectionRequestUserId(req);
}
async function assertEndpointAccess(
@@ -77,7 +85,7 @@ async function assertEndpointAccess(
res: ExpressResponse,
service: ChatChannelService,
): Promise<boolean> {
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);
});
@@ -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<Request, ConnectionManagementContext>();
export function connectionRequestContext(req: Request) { return contexts.get(req); }
export function connectionRequestUserId(req: Request) { return responsibleConnectionUser(req.actor); }
export function connectionRequestActor(req: Request) {
return { ...getActorInfo(req), ...contexts.get(req)?.audit, responsibleUserId: connectionRequestUserId(req), managementContext: contexts.get(req) };
}
export function requiredConnectionUserAction(
req: Request,
resource: ConnectionRequiredUserAction["resource"],
setupPath: string,
reason: string,
kind: ConnectionRequiredUserAction["kind"] = "authenticate",
): { requiredUserAction?: ConnectionRequiredUserAction } {
if (req.actor.type !== "agent") return {};
const userId = connectionRequestUserId(req);
if (!userId) throw forbidden("A responsible user is required for authentication");
return { requiredUserAction: { kind, userId, resource, setupPath, reason,
continuation: "Inspect this saved setup after the addressed user completes the required step." } };
}
function assertBoundedManagementSelectors(body: Record<string, unknown>) {
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<string, unknown>[] = [body];
const unique = new Set<string>();
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<string, unknown>);
}
}
for (const key of ["selectors", "access", "binding"]) {
const value = selected[key];
if (value && typeof value === "object" && !Array.isArray(value)) pending.push(value as Record<string, unknown>);
}
if (selected === body && body.config && typeof body.config === "object" && !Array.isArray(body.config)) {
const members = (body.config as Record<string, unknown>).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<string, unknown>);
}
}
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<ConnectionManagementContext> => {
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<string>();
const extraResources: ConnectionManagementResource[] = [];
const bindingTargets: { targetType: string; targetId: string }[] = [];
const selectors: Record<string, unknown>[] = [];
const policyAudiences: Record<string, unknown>[] = [];
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<string>();
const addSelector = (selected: Record<string, unknown>) => {
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<string, unknown>[]) => 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<string, Promise<unknown>>();
const lookup = <T,>(key: string, read: () => PromiseLike<T>): Promise<T> => {
let pending = lookupCache.get(key);
if (!pending) { pending = Promise.resolve(read()); lookupCache.set(key, pending); }
return pending as Promise<T>;
};
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<string, unknown> | 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<string | null>`${toolConnections.config}->>'sourceTemplateKey'`, provider: sql<string | null>`${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<T>(db: Db, req: Request, action: ConnectionManagementAction,
rows: T[], resource: (row: T) => Partial<ConnectionManagementResource>): Promise<T[]> {
if (req.actor.type !== "agent") return rows;
const context = connectionRequestContext(req);
if (!context) throw forbidden("Connection management authority is required");
const visible: T[] = [];
for (const row of rows) {
const decision = await connectionManagementService(db).capability(req.actor, action, { ...context.resource, ...resource(row) }, { mutation: false, 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<T extends { selectors: Record<string, unknown> }>(db: Db, req: Request, rows: T[]): Promise<T[]> {
if (req.actor.type !== "agent") return rows;
const context = connectionRequestContext(req);
if (!context) throw forbidden("Connection management authority is required");
const visible: T[] = [];
for (const row of rows) {
const resources: Partial<ConnectionManagementResource>[] = [{}];
const ids = (key: string) => [row.selectors[key], ...(Array.isArray(row.selectors[`${key}s`]) ? row.selectors[`${key}s`] as unknown[] : [])].filter((id): id is string => typeof id === "string");
for (const key of ["connectionId", "profileId", "gatewayId", "projectId", "issueId", "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;
}
+13 -9
View File
@@ -1,3 +1,4 @@
import { connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { Router, type ErrorRequestHandler, type Request } from "express";
import { z } from "zod";
import {
@@ -22,7 +23,8 @@ import type {
function actor(req: Request): EmailActor {
return req.actor.type === "agent"
? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined }
? { agentId: req.actor.agentId, runId: req.actor.runId ?? undefined,
...(connectionRequestContext(req) ? { userId: connectionRequestUserId(req) ?? undefined, managementContext: connectionRequestContext(req) } : {}) }
: {
userId: req.actor.userId ?? "board",
localImplicit: req.actor.source === "local_implicit",
@@ -86,7 +88,9 @@ const agentmailErrorHandler: ErrorRequestHandler = (error, _req, res, next) => {
export function emailRoutes(db: Db, service: EmailChannelService) {
const router = Router();
const managementGuard = connectionManagementGuard(db, "chat");
async function manager(req: Request, companyId: string) {
if (req.actor.type === "agent" && connectionRequestContext(req)) return;
assertBoard(req);
if (!hasCompanyAccess(req, companyId))
throw notFound("Email inbox not found");
@@ -104,7 +108,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
)
throw forbidden("Missing permission: tools:manage_connections");
}
router.get("/companies/:companyId/email/connections", async (req, res) => {
router.get("/companies/:companyId/email/connections", managementGuard("connections:read", "GET /companies/:companyId/email/connections", { personal: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
res.set("Cache-Control", "no-store").json(
@@ -112,7 +116,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
);
});
router.post(
"/companies/:companyId/email/connections",
"/companies/:companyId/email/connections", managementGuard("connections:configure", "POST /companies/:companyId/email/connections", { personal: true }),
validate(emailConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -129,7 +133,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
"/companies/:companyId/email/connections/:connectionId/inspect",
"/companies/:companyId/email/connections/:connectionId/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/inspect", { personal: true }),
async (req, res) => {
const companyId = req.params.companyId as string;
await manager(req, companyId);
@@ -144,7 +148,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
"/companies/:companyId/email/connections/:connectionId/check-address",
"/companies/:companyId/email/connections/:connectionId/check-address", managementGuard("connections:configure", "POST /companies/:companyId/email/connections/:connectionId/check-address", { personal: true }),
validate(emailAddressCheckSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
@@ -167,7 +171,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
);
});
router.post(
"/companies/:companyId/email/inspect",
"/companies/:companyId/email/inspect", managementGuard("connections:configure", "POST /companies/:companyId/email/inspect", { personal: true }),
validate(z.object({ apiKey: z.string().min(1).max(4096) }).strict()),
async (req, res) => {
await manager(req, req.params.companyId as string);
@@ -176,7 +180,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
"/companies/:companyId/email/inboxes",
"/companies/:companyId/email/inboxes", managementGuard("connections:configure", "POST /companies/:companyId/email/inboxes", { personal: true }),
validate(emailEndpointSetupSchema),
async (req, res) => {
await manager(req, req.params.companyId as string);
@@ -192,7 +196,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
"/email/inboxes/:endpointId/control",
"/email/inboxes/:endpointId/control", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/control", { personal: true }),
validate(
z.object({ action: z.enum(["pause", "resume", "remove"]) }).strict(),
),
@@ -205,7 +209,7 @@ export function emailRoutes(db: Db, service: EmailChannelService) {
},
);
router.post(
"/email/inboxes/:endpointId/reconnect",
"/email/inboxes/:endpointId/reconnect", managementGuard("connections:configure", "POST /email/inboxes/:endpointId/reconnect", { personal: true }),
validate(
z
.object({
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+45 -30
View File
@@ -1,7 +1,8 @@
import { filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { Router, type Request, type Response } from "express";
import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db";
import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations, toolRuntimeSlots } from "@paperclipai/db";
import {
humanizeConnectionDisplayName,
type PermissionKey,
@@ -349,13 +350,14 @@ function sendGatewayError(res: import("express").Response, err: unknown) {
export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const router = Router();
const managementGuard = connectionManagementGuard(db, "tool");
const access = accessService(db);
async function assertBoardPermission(req: import("express").Request, companyId: string, permissionKey: PermissionKey) {
assertBoard(req);
assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
if (req.actor.userId && await access.canUser(companyId, req.actor.userId, permissionKey)) return;
if (connectionRequestUserId(req) && await access.canUser(companyId, connectionRequestUserId(req)!, permissionKey)) return;
throw forbidden(`Missing permission: ${permissionKey}`);
}
@@ -374,18 +376,18 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
}
router.get("/companies/:companyId/tools/gateways", async (req, res) => {
router.get("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "GET /companies/:companyId/tools/gateways"), async (req, res) => {
try {
await assertBoardPermission(req, req.params.companyId, "tools:admin");
res.json({ gateways: await toolGateway.listNamedGateways(req.params.companyId) });
await assertBoardPermission(req, (req.params.companyId as string), "tools:admin");
res.json({ gateways: await filterConnectionManagementRows(db, req, "connections:gateway", await toolGateway.listNamedGateways((req.params.companyId as string)), row => ({ gatewayId: row.id })) });
} catch (err) {
sendGatewayError(res, err);
}
});
router.post("/companies/:companyId/tools/gateways", async (req, res) => {
router.post("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "POST /companies/:companyId/tools/gateways"), async (req, res) => {
try {
await assertBoardPermission(req, req.params.companyId, "tools:admin");
await assertBoardPermission(req, (req.params.companyId as string), "tools:admin");
const parsed = createToolMcpGatewaySchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(422).json({ error: "Invalid gateway payload", issues: parsed.error.issues });
@@ -393,9 +395,10 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
const actor = getActorInfo(req);
const gateway = await toolGateway.createNamedGateway({
companyId: req.params.companyId,
companyId: (req.params.companyId as string),
body: parsed.data,
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
managementContext: connectionRequestContext(req),
});
res.status(201).json(gateway);
} catch (err) {
@@ -403,9 +406,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
router.patch("/tool-gateway/gateways/:gatewayId", async (req, res) => {
router.patch("/tool-gateway/gateways/:gatewayId", managementGuard("connections:gateway", "PATCH /tool-gateway/gateways/:gatewayId"), async (req, res) => {
try {
assertBoard(req);
assertBoardOrAgent(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
@@ -418,15 +421,15 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
return;
}
const { companyId: _companyId, ...body } = parsed.data as typeof parsed.data & { companyId?: string };
res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: req.params.gatewayId, body }));
res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: (req.params.gatewayId as string), body, managementContext: connectionRequestContext(req) }));
} catch (err) {
sendGatewayError(res, err);
}
});
router.post("/tool-gateway/gateways/:gatewayId/tokens", async (req, res) => {
router.post("/tool-gateway/gateways/:gatewayId/tokens", managementGuard("connections:gateway", "POST /tool-gateway/gateways/:gatewayId/tokens"), async (req, res) => {
try {
assertBoard(req);
assertBoardOrAgent(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
@@ -441,25 +444,26 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const actor = getActorInfo(req);
res.status(201).json(await toolGateway.createNamedGatewayToken({
companyId,
gatewayId: req.params.gatewayId,
gatewayId: (req.params.gatewayId as string),
body: parsed.data,
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
managementContext: connectionRequestContext(req),
}));
} catch (err) {
sendGatewayError(res, err);
}
});
router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", async (req, res) => {
router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", managementGuard("connections:gateway", "POST /tool-gateway/gateway-tokens/:tokenId/revoke"), async (req, res) => {
try {
assertBoard(req);
assertBoardOrAgent(req);
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
return;
}
await assertBoardPermission(req, companyId, "tools:admin");
res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: req.params.tokenId }));
res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: (req.params.tokenId as string), managementContext: connectionRequestContext(req) }));
} catch (err) {
sendGatewayError(res, err);
}
@@ -470,7 +474,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
});
router.post("/tool-gateway/gateways/:gatewayId/mcp", async (req, res) => {
await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: req.params.gatewayId });
await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: (req.params.gatewayId as string) });
});
router.post("/tool-gateway/sessions", async (req, res) => {
@@ -659,22 +663,25 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
router.get("/tool-gateway/runtime-slots", async (req, res) => {
router.get("/tool-gateway/runtime-slots", managementGuard("connections:runtime", "GET /tool-gateway/runtime-slots"), async (req, res) => {
try {
assertBoard(req);
assertBoardOrAgent(req);
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
return;
}
await assertBoardPermission(req, companyId, "tools:manage_runtime");
res.json(await toolGateway.listRuntimeSlots(companyId));
const slots = await toolGateway.listRuntimeSlots(companyId);
const resources = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.companyId, companyId));
const byId = new Map(resources.map(row => [row.id, row]));
res.json(await filterConnectionManagementRows(db, req, "connections:runtime", slots.filter(row => byId.has(row.id)), row => ({ connectionId: byId.get(row.id)!.connectionId, issueId: byId.get(row.id)!.issueId })));
} catch (err) {
sendGatewayError(res, err);
}
});
router.post("/tool-gateway/runtime-slots/:slotId/stop", async (req, res) => {
router.post("/tool-gateway/runtime-slots/:slotId/stop", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/stop"), async (req, res) => {
try {
const companyId =
typeof req.body?.companyId === "string"
@@ -690,7 +697,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const actor = getActorInfo(req);
res.json(await toolGateway.stopRuntimeSlot({
companyId,
slotId: req.params.slotId,
slotId: (req.params.slotId as string),
managementContext: connectionRequestContext(req),
actor: {
agentId: actor.agentId,
runId: req.actor.type === "agent" ? req.actor.runId : null,
@@ -701,7 +709,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
router.post("/tool-gateway/runtime-slots/:slotId/restart", async (req, res) => {
router.post("/tool-gateway/runtime-slots/:slotId/restart", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/restart"), async (req, res) => {
try {
const companyId =
typeof req.body?.companyId === "string"
@@ -717,7 +725,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
const actor = getActorInfo(req);
res.json(await toolGateway.restartRuntimeSlot({
companyId,
slotId: req.params.slotId,
slotId: (req.params.slotId as string),
managementContext: connectionRequestContext(req),
actor: {
agentId: actor.agentId,
runId: req.actor.type === "agent" ? req.actor.runId : null,
@@ -728,9 +737,9 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
}
});
router.get("/tool-gateway/audit", async (req, res) => {
router.get("/tool-gateway/audit", managementGuard("connections:audit", "GET /tool-gateway/audit"), async (req, res) => {
try {
assertBoard(req);
assertBoardOrAgent(req);
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
if (!companyId) {
res.status(400).json({ error: "companyId is required" });
@@ -862,6 +871,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
row: toolCallEvents,
invocationId: toolInvocations.id,
invocationAgentId: toolInvocations.agentId,
invocationIssueId: toolInvocations.issueId,
invocationApplicationId: toolInvocations.applicationId,
invocationConnectionId: toolInvocations.connectionId,
invocationToolName: toolInvocations.toolName,
@@ -921,7 +931,12 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
return byTime !== 0 ? byTime : b.id.localeCompare(a.id);
});
const hasMore = candidates.length > limit;
const visible = candidates.slice(0, limit);
const window = candidates.slice(0, limit);
const visible = await filterConnectionManagementRows(db, req, "connections:audit", window, candidate => candidate.kind === "call"
? { connectionId: candidate.item.row.connectionId ?? candidate.item.invocationConnectionId,
issueId: candidate.item.invocationIssueId ?? candidate.item.row.issueId,
gatewayId: candidate.item.row.gatewayId }
: { connectionId: candidate.item.connectionId });
const agentIds = [...new Set(visible.flatMap((item) => [
item.kind === "call" ? item.item.row.agentId : item.item.agentId,
item.kind === "call" ? item.item.invocationAgentId : null,
@@ -1068,7 +1083,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
};
});
const last = visible.at(-1);
const last = window.at(-1);
res.json({
events,
nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null,
+2 -1
View File
@@ -68,6 +68,7 @@ export interface LogActivityInput {
issueId?: string | null;
details?: Record<string, unknown> | null;
responsibleUserIdOverride?: string | null;
authorization?: { action: string; operation: string; reason: string };
}
@@ -163,7 +164,7 @@ export function publishActivity(publication: ActivityPublication) {
}
export async function persistActivity(db: Db, input: LogActivityInput) {
const redactedDetails = await redactActivityDetails(db, input.details ?? null);
const redactedDetails = await redactActivityDetails(db, input.authorization ? { ...input.details, permissionDecision: input.authorization, ...(input.agentApiKeyId ? { agentApiKeyId: input.agentApiKeyId } : {}) } : input.details ?? null);
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
const insert = (runner: Db) => runner.insert(activityLog).values({
companyId: input.companyId,
+7 -4
View File
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { and, eq, ne, sql } from "drizzle-orm";
import { aiConnectionPools, aiConnectionRouterCursors, aiConnectionTaskPins, companySecrets, connectionGrants, instanceSettings, plugins, toolApplications, toolConnections, toolConnectionInstalls, type Db } from "@paperclipai/db";
@@ -117,7 +118,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
}
return result;
}
async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string) {
async function save(pluginKey: string, input: AiConnectionPoolSaveInput, userId: string, managementContext?: ConnectionManagementContext) {
await enabled();
const plugin = await owner(pluginKey);
const config = aiConnectionPoolConfigSchema.parse(input.config);
@@ -127,6 +128,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
poolMemberRuntimeConfig(member, "paperclip_runner");
}
const id = input.id ?? randomUUID();
await managementContext?.reauthorize();
await db.transaction(async (tx) => {
if (input.id) {
const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, id), eq(aiConnectionPools.companyId, input.companyId), eq(aiConnectionPools.pluginKey, pluginKey))).for("update");
@@ -146,13 +148,14 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
await tx.insert(aiConnectionRouterCursors).values({ poolId: id, companyId: input.companyId });
await tx.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: id, targetType: "company", targetId: input.companyId, createdByUserId: userId });
}
await logActivity(tx as unknown as Db, { companyId: input.companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } });
await logActivity(tx as unknown as Db, { companyId: input.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.pool_saved", entityType: "tool_connection", entityId: id, details: { enabled: config.enabled, memberCount: config.members.length, mode: config.mode } });
});
return (await list(input.companyId)).find((p) => p.id === id)!;
}
async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string) {
async function remove(companyId: string, poolId: string, expectedRevision: number, userId: string, managementContext?: ConnectionManagementContext) {
// Archive the virtual connection; cascading deletion would erase task pins.
// Cleanup remains available when the experimental flag or plugin is off.
await managementContext?.reauthorize();
await db.transaction(async tx => {
const [row] = await tx.select().from(aiConnectionPools).where(and(eq(aiConnectionPools.id, poolId), eq(aiConnectionPools.companyId, companyId))).for("update");
if (!row) throw notFound("Connection pool not found");
@@ -161,7 +164,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
if (row.revision !== expectedRevision) throw conflict("Pool changed; reload before deleting");
await tx.update(aiConnectionPools).set({ config: { ...row.config, enabled: false }, revision: row.revision + 1, updatedAt: new Date() }).where(eq(aiConnectionPools.id, poolId));
await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(and(eq(toolConnections.id, poolId), eq(toolConnections.companyId, companyId)));
await logActivity(tx as unknown as Db, { companyId, actorType: "user", actorId: userId, action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } });
await logActivity(tx as unknown as Db, { companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.pool_deleted", entityType: "tool_connection", entityId: poolId, details: { retainedTaskPins: true } });
});
return { ok: true };
}
+5 -2
View File
@@ -1,4 +1,5 @@
import { notifyLoginSessionChanged } from "./login-cleanup-notifications.js";
import type { ConnectionManagementContext } from "./connection-management.js";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
@@ -572,7 +573,9 @@ export function aiConnectionService(db: Db) {
verifiedCredential: string,
loginSession?: { id: string; complete?: (tx: Db) => Promise<void> },
attemptStartedAt = new Date(),
managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
const routing = "routing" in input ? input.routing : undefined;
if (!(await membership(companyId, userId)))
throw forbidden("An active company member must own this connection");
@@ -610,6 +613,7 @@ export function aiConnectionService(db: Db) {
const id = reconnect?.connection.id ?? randomUUID();
const grantId = reconnect?.grant.id ?? randomUUID();
const persist = () => withAccountHomeSecretMutationLock(undefined, companyId, () => db.transaction(async (tx) => {
await managementContext?.reauthorize();
const secrets = secretService(tx);
if (loginSession) {
const [session] = await tx
@@ -902,8 +906,7 @@ export function aiConnectionService(db: Db) {
await loginSession?.complete?.(tx as unknown as Db);
await logActivity(tx as unknown as Db, {
companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: reconnect
? "ai_connection.reconnected"
: "ai_connection.connected",
+78 -11
View File
@@ -54,6 +54,10 @@ function measureWorkspaceAuthorization<T>(phase: Extract<ExecutionPhase, `author
return isRunPhaseActive("workspace.access.task_authorization") || isRunPhaseActive("workspace.access.authorization")
? measureRunPhase(phase, work) : work();
}
import {
CONNECTION_MANAGEMENT_PERMISSIONS, isConnectionManagementAction,
type ConnectionManagementAction, type ConnectionManagementResource,
} from "./connection-management-policy.js";
export type AuthorizationActor =
{
@@ -87,6 +91,7 @@ export type AuthorizationActor =
export type AuthorizationAction =
| PermissionKey
| ConnectionManagementAction
| "agent_config:read"
| "agent_config:update"
| "agent_instructions:update"
@@ -108,6 +113,7 @@ export type AuthorizationAction =
| "secrets:propose";
export type AuthorizationResource =
| ConnectionManagementResource
| { type: "company"; companyId: string }
| { type: "agent"; companyId: string; agentId?: string | null }
| { type: "project"; companyId: string; projectId?: string | null }
@@ -180,6 +186,7 @@ function companyIdForResource(resource: AuthorizationResource) {
}
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
if (isConnectionManagementAction(action)) return CONNECTION_MANAGEMENT_PERMISSIONS[action];
if (action === "project:write_workspace") return "tasks:assign";
if (action === "agent_config:read" || action === "agent_config:update" || action === "agent_instructions:update" || action === "skill_config:update") {
return null;
@@ -442,6 +449,18 @@ async function scopeAllows(
const requestedUserId = typeof requestedScope.userId === "string" ? requestedScope.userId : null;
let constrained = false;
for (const key of ["connection", "profile", "gateway", "provider", "purpose", "operation"] as const) {
const idKey = ["connection", "profile", "gateway"].includes(key) ? `${key}Id` : key;
const values = scopeValuesForKeys(grantScope, [idKey, `${idKey}s`]);
if (values.length === 0) {
if (Object.hasOwn(grantScope, idKey) || Object.hasOwn(grantScope, `${idKey}s`)) return false;
continue;
}
constrained = true;
const requested = requestedScope[idKey];
if (!scopeIncludesId(values, typeof requested === "string" ? requested : null)) return false;
}
const projectIds = [
...scopeValueList(grantScope.projectId),
...scopeValueList(grantScope.projectIds),
@@ -1096,6 +1115,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
if (!runId) return null;
const row = await db
.select({
issueId: heartbeatRuns.issueId,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`,
@@ -1105,11 +1125,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
.then((rows) => rows[0] ?? null);
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
const issueId = typeof context?.issueId === "string"
const issueId = row.issueId ?? (typeof context?.issueId === "string"
? context.issueId.trim()
: typeof context?.taskId === "string"
? context.taskId.trim()
: "";
: "");
return issueId || null;
}
@@ -1151,9 +1171,9 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
}
async function loadResourceContext(resource: AuthorizationResource) {
const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null;
const issue = (resource.type === "issue" || resource.type === "connection_management") && resource.issueId ? await loadIssue(resource.issueId) : null;
const projectId =
resource.type === "issue"
resource.type === "issue" || resource.type === "connection_management"
? issue?.projectId ?? resource.projectId ?? null
: resource.type === "project"
? resource.projectId ?? null
@@ -1168,7 +1188,12 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
companyId: string;
resource: AuthorizationResource;
}): Promise<TrustPresetResolution> {
const { issue, project } = await loadResourceContext(input.resource);
const runIssueId = input.resource.type === "connection_management"
? await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgent.id)
: null;
const { issue, project } = await loadResourceContext(runIssueId
? { type: "issue", companyId: input.companyId, issueId: runIssueId }
: input.resource);
const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id);
const resolution = resolveCoreTrustPreset({
companyId: input.companyId,
@@ -1302,6 +1327,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
}
if (
isConnectionManagementAction(input.action) ||
input.action === "company_scope:read" ||
// Agent creation is a company-wide privileged action. The default-on
// canCreateAgents flag must never reach the legacy creator allow when
@@ -2054,6 +2080,40 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
explanation: "Board user id is required.",
});
}
if (isConnectionManagementAction(input.action) && input.resource.type === "connection_management") {
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
if (!membership || (!input.resource.readOnly && input.action !== "connections:read" && membership.membershipRole === "viewer")) {
return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Connection management requires an active, eligible company membership." });
}
if (input.resource.readOnly || input.action === "connections:read") {
return allow({ action: input.action, reason: "allow_company_member", explanation: "Management metadata is visible to active company members, subject to resource visibility." });
}
if (input.resource.selfServiceUserId === input.actor.userId &&
["connections:read", "connections:configure", "connections:profiles"].includes(input.action)) {
return allow({ action: input.action, reason: "allow_self", explanation: "Allowed by personal account or connection creator authority." });
}
if (input.action === "connections:profiles" && input.resource.selfServiceAgentInstalls) {
return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
action: input.action, permissionKey: "agents:configure", scope: input.scope });
}
const userPermission = input.action === "connections:gateway" ? "tools:admin"
: input.action === "connections:profiles" && input.resource.operation.includes("/delegations") ? "tools:manage_connections"
: permissionKey!;
const granted = await decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
action: input.action, permissionKey: userPermission, scope: input.scope });
// Existing human setup tests are available to connection managers. The
// acting agent still needs tools:use and normal execution policy.
if (!granted.allowed && granted.reason === "deny_missing_grant" && input.action === "connections:test") {
return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
action: input.action, permissionKey: "tools:manage_connections", scope: input.scope });
}
if (!granted.allowed && granted.reason === "deny_missing_grant" && input.action === "connections:profiles" &&
["owner", "admin"].includes(membership.membershipRole ?? "")) {
return decidePrincipalGrant({ companyId, principalType: "user", principalId: input.actor.userId,
action: input.action, permissionKey: "tools:admin", scope: input.scope });
}
return granted;
}
if (taskAssignmentAction) {
if (!(await assignmentTargetIsInCompany(input.resource))) {
return deny({
@@ -2216,6 +2276,11 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
});
}
if (isConnectionManagementAction(input.action) &&
(input.actor.keyScope?.kind === "skill_test" || input.actor.keyScope?.kind === "task_bridge")) {
return deny({ action: input.action, reason: "deny_scope", explanation: "Scoped test and task bridge credentials cannot administer connections." });
}
if (input.actor.keyScope?.kind === "skill_test") {
const skillTestDecision = decideSkillTestAccess({
action: input.action,
@@ -2664,6 +2729,10 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
agentDecision: AuthorizationDecision,
): Promise<AuthorizationDecision> {
const responsibleUserId = input.actor.onBehalfOfUserId?.trim();
if (input.actor.type === "agent" && isConnectionManagementAction(input.action) && !responsibleUserId) {
return deny({ action: input.action, reason: "deny_missing_membership", code: "RESPONSIBLE_USER_UNAVAILABLE",
explanation: "Connection management requires an authenticated responsible user." });
}
if (
input.actor.type !== "agent" ||
input.action === "inbox:manage" ||
@@ -2674,11 +2743,9 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
}
const companyId = companyIdForResource(input.resource);
const snapshot = await getResponsibleUserSnapshot({
actor: input.actor,
companyId,
userId: responsibleUserId,
});
const snapshot = isConnectionManagementAction(input.action)
? await loadResponsibleUserSnapshot(companyId, responsibleUserId)
: await getResponsibleUserSnapshot({ actor: input.actor, companyId, userId: responsibleUserId });
const denyCode: AuthorizationDecision["code"] =
snapshot.userExists && snapshot.activeMembership
? "RESPONSIBLE_USER_UNAUTHORIZED"
@@ -2750,7 +2817,7 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
responsibleUserId,
}, "responsible-user authorization intersection denied");
return input.action !== "agent_instructions:update" && responsibleUserAuthzShadowMode() ? agentDecision : denied;
return input.action !== "agent_instructions:update" && !isConnectionManagementAction(input.action) && responsibleUserAuthzShadowMode() ? agentDecision : denied;
}
async function applyAuthorizingUserIntersection(
+10 -2
View File
@@ -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<ChatProvider, readonly string[]> = {
};
const MAX_ERROR_TEXT = 2_000;
const RECONNECT_CREDENTIAL_KEYS: Partial<Record<ChatProvider, readonly string[]>> = {
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,
+50 -9
View File
@@ -1,3 +1,4 @@
import { connectionManagementService, type ConnectionManagementContext } from "../connection-management.js";
import { providerEffectPayload } from "./provider-effects.js";
import { slackSlashTaskRecoveryPayload } from "./slack/task-start.js";
@@ -161,7 +162,7 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) {
});
}
async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) {
async function 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<string> | 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<string>();
const issueVisibility = new Map<string, boolean>();
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,
};
@@ -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<ReturnType<typeof createChatCredentials>, "
leasedChatProvider: (provider: string) => boolean;
PROVIDER_LABELS: Record<ChatProvider, string>;
SUPPLIED_CREDENTIAL_KEYS: Record<ChatProvider, readonly string[]>;
RECONNECT_CREDENTIAL_KEYS?: Partial<Record<ChatProvider, readonly string[]>>;
PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number;
isSupportedTelegramWebhookBaseUrl: (url: string | null) => boolean;
slackRegistration: Pick<ReturnType<typeof slackChatRegistrationService>, "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"
@@ -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<string, string | null> {
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<typeof secretService>;
@@ -67,6 +82,7 @@ type ChatCredentialsOptions = {
redactError: (error: unknown) => string;
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
verifyPhotonCredentials: ReturnType<typeof createPhotonCredentialChecks>["verifyPhotonCredentials"];
preservesWebhookVerification?: (endpoint: EndpointRow, setup: EndpointRow["setup"], credentials: Record<string, string>) => 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<string, string>,
credentialLease: CredentialMutationLeaseGuard,
actorUserId?: string | null,
onCommitted?: (tx: Db) => Promise<void>,
) {
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<string, string> = {};
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) {
+15 -7
View File
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "../connection-management.js";
import { slackSetupState } from "./slack/setup.js";
import { assertChatExecutionDefaultsAccess } from "../chat-execution-defaults.js";
@@ -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,
@@ -1,4 +1,5 @@
import type { ChatEndpointSetupState } from "@paperclipai/shared";
import type { ConnectionManagementContext } from "../../connection-management.js";
import type { VerifiedProviderIdentity } from "../runtime-types.js";
import { createSetupProbes } from "../setup-probes.js";
@@ -77,8 +78,8 @@ type GitHubAppSetupOptions = {
reconcileProviderResourceRows: ReturnType<typeof createChatResourceReconciliation>["reconcileProviderResourceRows"];
listResources: ReturnType<typeof createChatResourceReconciliation>["listResources"];
replaceResources: ReturnType<typeof createChatResourceReconciliation>["replaceResources"];
update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null) => Promise<ChatEndpoint>;
configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null) => Promise<ChatEndpoint>;
update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise<ChatEndpoint>;
configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise<ChatEndpoint>;
test: ReturnType<typeof createSetupProbes>["test"];
githubWizardOrigin?: string | null;
handleWebhook: (publicId: string, provider: "github", request: Request) => Promise<Response>;
@@ -88,7 +89,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<string, string>, source: "manifest" | "existing_app" = "existing_app") {
async function storeGitHubApp(endpointId: string, userId: string, credentials: Record<string, string>, source: "manifest" | "existing_app" = "existing_app", managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
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,
@@ -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<EndpointRecord | null>;
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise<ToolCredentialSecretRef[]>;
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null, onCommitted?: (tx: Db) => Promise<void>) => Promise<ToolCredentialSecretRef[]>;
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
invalidateRuntime: (endpointId: string) => Promise<boolean>;
@@ -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,
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "../connection-management.js";
import { imessageResourceParticipants } from "./imessage-photon/resources.js";
import { createGitHubResources, reconcileGitHubRepositoryCoordinates } from "./github/resources.js";
import { baseTeamsConversationId } from "./microsoft-teams/resources.js";
@@ -166,7 +167,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
endpointId: string,
updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>,
actorUserId?: string | null,
options?: { initialGitHubImport?: boolean },
options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext },
) {
await updateResourceSelection(endpointId, updates, actorUserId, options);
return listResources(endpointId);
@@ -176,8 +177,9 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
endpointId: string,
updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>,
actorUserId?: string | null,
options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean },
options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean; managementContext?: ConnectionManagementContext },
) {
await options?.managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
const bulk = options?.allGitHubRepositoriesEnabled !== undefined;
@@ -263,7 +265,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
await assertChatExecutionDefaultsAccess(db, {
companyId: endpoint.companyId,
assigneeAgentId: endpoint.assignedAgentId,
actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
actor: options?.managementContext?.actor ?? { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
defaults: entry.executionDefaults,
}, tx);
finalDefaults.set(entry.id, entry.executionDefaults);
@@ -306,8 +308,7 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
tx as unknown as Db,
{
companyId: endpoint.companyId,
actorType: "user",
actorId: actorUserId ?? "board",
...(options?.managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.resources_updated",
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "../connection-management.js";
import type { EndpointRow, DbOrTransaction } from "./runtime-types.js";
import { notifyChatEndpointWork } from "../chat-work-notifications.js";
@@ -94,7 +95,8 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) {
return { messageReceivedAt: at?.toISOString() ?? null };
}
async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) {
async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
return withCredentialMutationLease(
@@ -287,6 +289,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) {
}
}
await options.setupTestActivationBarrier?.();
await managementContext?.reauthorize();
const expectedGeneration = runtimeGeneration(endpoint.setup);
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
@@ -339,7 +342,7 @@ export function createSetupProbes(db: Db, options: SetupProbesOptions) {
})
.where(eq(toolConnections.id, endpoint.connectionId));
if (optionalSlackTest) await logActivity(tx as unknown as Db, {
companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser,
companyId: endpoint.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: finishOptions!.optionalSlackTestForUser }),
action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId,
details: { conversationTestOptional: true },
});
@@ -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,
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "./connection-management.js";
import { githubBotCredentials } from "./chat-github-client.js";
import { toolAccessPolicyService } from "./tool-access-policy.js";
import { agents, toolCatalogEntries } from "@paperclipai/db";
@@ -90,7 +91,9 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) {
endpointId: string,
input: unknown,
userId: string,
managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
const bot = await endpoint(endpointId);
await member(bot.companyId, userId);
const parsed = updateGitHubChatConfigurationSchema.parse(input);
@@ -185,8 +188,7 @@ export function githubChatManagementService(db: Db, fetchImpl = fetch) {
await syncGitHubBotTools(tx, bot, userId, config.toolsEnabled);
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.configuration_updated",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "./connection-management.js";
import { createHash, randomBytes } from "node:crypto";
import { and, eq, gt, sql } from "drizzle-orm";
import {
@@ -52,6 +53,7 @@ export function githubChatRegistrationService(
clientId?: string;
clientSecret?: string;
},
managementContext?: ConnectionManagementContext,
) => Promise<void>;
},
) {
@@ -59,7 +61,9 @@ export function githubChatRegistrationService(
endpointId: string,
userId: string,
input: string | GitHubAppRegistrationInput,
managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
const {
name,
ownerType = "personal",
@@ -111,6 +115,7 @@ export function githubChatRegistrationService(
.where(eq(companies.id, endpoint.companyId));
const state = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 30 * 60_000);
await managementContext?.reauthorize();
await db.transaction(async (tx) => {
const [lockedEndpoint] = await tx
.select()
@@ -151,6 +156,7 @@ export function githubChatRegistrationService(
appName: name.trim(),
handoff: {
cloudId: "",
...(managementContext?.actor.type === "board" && managementContext.actor.sessionId ? { browserSessionId: managementContext.actor.sessionId } : {}),
redemptionId: randomBytes(32).toString("base64url"),
returnState: state,
},
@@ -158,8 +164,7 @@ export function githubChatRegistrationService(
});
await logActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.registration_started",
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -234,7 +239,8 @@ export function githubChatRegistrationService(
},
};
}
async function resumeRegistration(endpointId: string, userId: string) {
async function resumeRegistration(endpointId: string, userId: string, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
const [session] = await db
.select()
.from(chatGitHubRegistrations)
@@ -277,7 +283,8 @@ export function githubChatRegistrationService(
expiresAt: session.expiresAt,
});
}
async function complete(state: string, code: string) {
async function complete(state: string, code: string, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
if (
!/^[A-Za-z0-9_-]{43}$/.test(state) ||
!/^[a-zA-Z0-9_-]{1,256}$/.test(code)
@@ -353,6 +360,7 @@ export function githubChatRegistrationService(
app.owner?.login?.toLowerCase() !== session.ownerLogin?.toLowerCase())
)
throw badRequest("GitHub returned an App owned by a different account");
await managementContext?.reauthorize();
await options.storeApp(session.endpointId, session.userId, {
appId: String(app.id),
privateKey: app.pem,
@@ -360,7 +368,7 @@ export function githubChatRegistrationService(
slug: app.slug,
clientId: app.client_id,
clientSecret: app.client_secret,
});
}, managementContext);
await db
.update(chatGitHubRegistrations)
.set({ status: "completed" })
+86 -43
View File
@@ -1,3 +1,5 @@
import type { AuthorizationActor } from "./authorization.js";
import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js";
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, sql } from "drizzle-orm";
import {
@@ -73,6 +75,7 @@ export function githubChatWizardService(
endpointId: string,
userId: string,
input: GitHubAppRegistrationInput,
managementContext?: ConnectionManagementContext,
) => Promise<{
registrationUrl: string;
manifest: Record<string, unknown>;
@@ -81,16 +84,18 @@ export function githubChatWizardService(
resumeDirect?: (
endpointId: string,
userId: string,
managementContext?: ConnectionManagementContext,
) => Promise<{
registrationUrl: string;
manifest: Record<string, unknown>;
expiresAt: string;
}>;
completeDirect?: (state: string, code: string) => Promise<string>;
completeDirect?: (state: string, code: string, managementContext?: ConnectionManagementContext) => Promise<string>;
storeApp: (
endpointId: string,
userId: string,
credentials: Record<string, string>,
managementContext?: ConnectionManagementContext,
) => Promise<unknown>;
storeCredentials: (
endpointId: string,
@@ -100,16 +105,17 @@ export function githubChatWizardService(
refreshRepositories: (
endpointId: string,
userId: string,
managementContext?: ConnectionManagementContext,
) => Promise<Resource[]>;
resources: (endpointId: string) => Promise<Resource[]>;
replaceResources: (
endpointId: string,
updates: Array<{ id: string; enabled: boolean }>,
userId: string,
options?: { initialGitHubImport?: boolean },
options?: { initialGitHubImport?: boolean; managementContext?: ConnectionManagementContext },
) => Promise<unknown>;
configure: (endpointId: string, userId: string) => Promise<unknown>;
finish: (endpointId: string, userId: string) => Promise<unknown>;
configure: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise<unknown>;
finish: (endpointId: string, userId: string, managementContext?: ConnectionManagementContext) => Promise<unknown>;
},
) {
const fetchImpl = options.fetch ?? fetch;
@@ -214,8 +220,9 @@ export function githubChatWizardService(
async function start(
id: string,
userId: string,
input: GitHubAppRegistrationInput,
input: GitHubAppRegistrationInput, managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
const bot = await endpoint(id, userId);
if (bot.botExternalId)
throw conflict(
@@ -229,13 +236,13 @@ export function githubChatWizardService(
throw conflict(
"Recover the existing App from GitHub settings; its registration cannot safely be repeated.",
);
if (prior.handoff) return advance(id, userId);
if (prior.handoff) return advance(id, userId, managementContext);
// The direct flow's hashed state cannot be reconstructed. Do not create a duplicate App.
throw conflict(
"An App registration is already pending. Complete its open GitHub page, or use existing-App recovery.",
);
}
await saveDraft(id, userId, input);
await saveDraft(id, userId, input, managementContext);
// Only a fresh registration may import GitHub's initial selection. Old
// drafts without this marker retain even an entirely disabled selection.
if (
@@ -249,7 +256,7 @@ export function githubChatWizardService(
return {
endpointId: id,
state: "create",
registration: await options.startDirect(id, userId, input),
registration: await options.startDirect(id, userId, input, managementContext),
} satisfies GitHubAppWizardState;
return {
endpointId: id,
@@ -302,13 +309,12 @@ export function githubChatWizardService(
ownerLogin: input.ownerLogin,
appName: input.name,
expiresAt: new Date(Date.now() + 30 * 60_000),
handoff: { cloudId: nonce(), returnState, redemptionId: nonce() },
handoff: { cloudId: nonce(), returnState, redemptionId: nonce(), ...(managementContext?.actor.type === "board" && managementContext.actor.sessionId ? { browserSessionId: managementContext.actor.sessionId } : {}) },
})
.returning();
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.registration_started",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -403,6 +409,7 @@ export function githubChatWizardService(
async function exchangeCloudManifest(
session: typeof chatGitHubRegistrations.$inferSelect,
claimId: string,
managementContext?: ConnectionManagementContext,
) {
const cloud = connector();
if (!cloud || !session.handoff)
@@ -471,6 +478,7 @@ export function githubChatWizardService(
app.owner.login?.toLowerCase() !== session.ownerLogin.toLowerCase())
)
throw forbidden("GitHub returned an App owned by a different account");
await managementContext?.reauthorize();
await options.storeApp(session.endpointId, session.userId, {
appId: String(app.id),
privateKey: app.pem,
@@ -478,7 +486,7 @@ export function githubChatWizardService(
clientId: app.client_id,
clientSecret: app.client_secret,
slug: app.slug,
});
}, managementContext);
await db
.update(chatGitHubRegistrations)
.set({
@@ -500,7 +508,8 @@ export function githubChatWizardService(
session.expiresAt <= new Date() && !session.consumedAt &&
!session.handoff.manifestClaimId;
}
async function restartRegistration(id: string, userId: string, registrationId: string) {
async function restartRegistration(id: string, userId: string, registrationId: string, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
const bot = await endpoint(id, userId);
const cloud = connector();
if (!cloud) throw conflict("Restore Paperclip Cloud enrollment before continuing");
@@ -532,20 +541,21 @@ export function githubChatWizardService(
handoff: { cloudId: nonce(), returnState, redemptionId: nonce(), renewalOf: prior.id },
}).returning();
await logActivity(tx as unknown as Db, {
companyId: bot.companyId, actorType: "user", actorId: userId,
companyId: bot.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.registration_restarted", entityType: "tool_connection", entityId: bot.connectionId,
details: { endpointId: id, registrationId: next!.id, previousRegistrationId: prior.id, appNotCreated: true },
});
return next!;
});
await setup(bot, { cloudRegistrationId: created.handoff!.cloudId, registrationStatus: "pending", stage: "connect" });
return advance(id, userId);
return advance(id, userId, managementContext);
}
async function saveDraft(
id: string,
userId: string,
input: GitHubAppRegistrationInput,
input: GitHubAppRegistrationInput, managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
const bot = await endpoint(id, userId);
await db.transaction(async (tx) => {
await tx
@@ -571,8 +581,7 @@ export function githubChatWizardService(
.where(eq(chatEndpoints.id, id));
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.draft_saved",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -581,7 +590,8 @@ export function githubChatWizardService(
});
return { saved: true as const };
}
async function repairCloud(id: string, userId: string) {
async function repairCloud(id: string, userId: string, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
const bot = await endpoint(id, userId),
session = await registration(bot),
cloud = connector();
@@ -608,8 +618,7 @@ export function githubChatWizardService(
throw conflict("Restore this App's Cloud gateway before continuing");
await logActivity(db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.webhook_repair_started",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -659,8 +668,7 @@ export function githubChatWizardService(
}
await logActivity(db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: "chat_github.webhook_repaired",
entityType: "tool_connection",
entityId: bot.connectionId,
@@ -669,12 +677,23 @@ export function githubChatWizardService(
}
async function advance(
id: string,
userId: string,
userId: string, managementContext?: ConnectionManagementContext,
): Promise<GitHubAppWizardState> {
await managementContext?.reauthorize();
// Bind an agent-created handoff when its addressed human opens the saved setup.
if (managementContext?.actor.type === "board" && managementContext.actor.sessionId) {
const bot = await endpoint(id, userId);
const session = await registration(bot);
if (session && session.userId === userId && !session.handoff?.browserSessionId) {
await db.update(chatGitHubRegistrations).set({
handoff: sql`coalesce(${chatGitHubRegistrations.handoff}, '{}'::jsonb) || ${JSON.stringify({ browserSessionId: managementContext.actor.sessionId })}::jsonb`,
}).where(and(eq(chatGitHubRegistrations.id, session.id), sql`${chatGitHubRegistrations.handoff}->>'browserSessionId' is null`));
}
}
// Multiple open tabs for this manager share one operation, not concurrent vault writes.
const key = `${id}:${userId}`;
if (pending.has(key)) return pending.get(key)!;
const work = advanceOnce(id, userId);
const work = advanceOnce(id, userId, managementContext);
pending.set(key, work);
try {
return await work;
@@ -684,8 +703,9 @@ export function githubChatWizardService(
}
async function advanceOnce(
id: string,
userId: string,
userId: string, managementContext?: ConnectionManagementContext,
): Promise<GitHubAppWizardState> {
await managementContext?.reauthorize();
let bot = await endpoint(id, userId);
if (["paused", "revoked"].includes(bot.status))
return {
@@ -737,7 +757,7 @@ export function githubChatWizardService(
session.handoff.manifestClaimId &&
!hasAppCredentials
) {
await exchangeCloudManifest(session, session.handoff.manifestClaimId);
await exchangeCloudManifest(session, session.handoff.manifestClaimId, managementContext);
bot = await endpoint(id, userId);
const [savedConnection] = await db
.select({ refs: toolConnections.credentialSecretRefs })
@@ -790,7 +810,7 @@ export function githubChatWizardService(
session.handoff.webhookSecretHash !==
hash(stored.webhookSecret ?? ""))
) {
await repairCloud(id, userId);
await repairCloud(id, userId, managementContext);
bot = await endpoint(id, userId);
}
if (session.status !== "completed")
@@ -826,7 +846,7 @@ export function githubChatWizardService(
return {
endpointId: id,
state: "create",
registration: await options.resumeDirect(id, userId),
registration: await options.resumeDirect(id, userId, managementContext),
};
return {
endpointId: id,
@@ -857,7 +877,7 @@ export function githubChatWizardService(
bot.setup.github?.initialSetupPending
) {
try {
await options.refreshRepositories(id, userId);
await options.refreshRepositories(id, userId, managementContext);
} catch (error) {
return {
endpointId: id,
@@ -892,7 +912,7 @@ export function githubChatWizardService(
.filter((resource) => resource.availability === "available")
.map((resource) => ({ id: resource.id, enabled: true })),
userId,
{ initialGitHubImport: true },
{ initialGitHubImport: true, managementContext },
);
}
if (initializeApp && saved.revision === 0 && bot.status !== "active")
@@ -911,7 +931,9 @@ export function githubChatWizardService(
},
},
userId,
managementContext,
);
await managementContext?.reauthorize();
await setup(bot, {
initialRepositoriesImported: true,
initialSetupPending: false,
@@ -946,7 +968,7 @@ export function githubChatWizardService(
bot.status !== "active" &&
(bot.status !== "verifying" || bot.setup.step === "provider_setup")
)
await options.configure(id, userId);
await options.configure(id, userId, ...(managementContext ? [managementContext] : []));
bot = await endpoint(id, userId);
let linked = await linkedIdentity(bot, userId);
if (!linked) {
@@ -1003,7 +1025,7 @@ export function githubChatWizardService(
);
const connectionReady = connectionChecks.every((check) => check.ok);
if (connectionReady && bot.status !== "active")
await options.finish(id, userId);
await options.finish(id, userId, ...(managementContext ? [managementContext] : []));
return {
endpointId: id,
state: connectionReady ? "connected" : "verify",
@@ -1102,19 +1124,32 @@ export function githubChatWizardService(
);
}
}
async function startIdentity(id: string, userId: string) {
async function authorizeBrowserReturn(session: typeof chatGitHubRegistrations.$inferSelect, actor?: AuthorizationActor, identity = false) {
// Internal provider workflows already own their authenticated subject. HTTP
// callbacks always pass their actual browser actor before consuming state.
if (!actor) return;
const sessionId = identity ? session.handoff?.identitySessionId : session.handoff?.browserSessionId;
if (actor.type !== "board" || actor.userId !== session.userId || (sessionId && actor.sessionId !== sessionId)) throw forbidden("Open this saved setup in the configuring user's browser session");
const bot = await endpoint(session.endpointId, session.userId);
return connectionManagementService(db).authorize(actor, "connections:configure", {
type: "connection_management", companyId: bot.companyId, connectionId: bot.connectionId,
targetAgentId: bot.assignedAgentId, purpose: "chat", provider: "github", operation: "github.authentication.return",
});
}
async function startIdentity(id: string, userId: string, actor?: AuthorizationActor) {
const bot = await endpoint(id, userId),
session = await registration(bot);
if (!session || session.userId !== userId)
throw forbidden("The configuring member must link their GitHub account");
return withIdentityLease(session.id, (assertOwned) =>
startIdentityLocked(id, userId, assertOwned),
startIdentityLocked(id, userId, assertOwned, actor),
);
}
async function startIdentityLocked(
id: string,
userId: string,
assertOwned: () => Promise<void>,
actor?: AuthorizationActor,
) {
const bot = await endpoint(id, userId),
session = await registration(bot);
@@ -1138,7 +1173,7 @@ export function githubChatWizardService(
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`,
handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ ...(actor?.type === "board" && actor.sessionId ? { identitySessionId: actor.sessionId } : {}), identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -1179,6 +1214,7 @@ export function githubChatWizardService(
session: typeof chatGitHubRegistrations.$inferSelect,
code: string,
redirectUri: string,
actor?: AuthorizationActor,
) {
return withIdentityLease(session.id, async (assertOwned) => {
const [current] = await db
@@ -1191,7 +1227,7 @@ export function githubChatWizardService(
session.handoff?.identityStateHash
)
return resume(await endpoint(session.endpointId, session.userId));
return completeIdentityLocked(current, code, redirectUri, assertOwned);
return completeIdentityLocked(current, code, redirectUri, assertOwned, actor);
});
}
async function completeIdentityLocked(
@@ -1199,6 +1235,7 @@ export function githubChatWizardService(
code: string,
redirectUri: string,
assertOwned: () => Promise<void>,
actor?: AuthorizationActor,
) {
const bot = await endpoint(session.endpointId, session.userId);
if (
@@ -1283,6 +1320,7 @@ export function githubChatWizardService(
!/^[A-Za-z0-9-]{1,39}$/.test(user.login)
)
throw conflict("GitHub returned an invalid account");
await authorizeBrowserReturn(session, actor, true);
// OAuth token remains transient; only an expiring observed identity is saved.
await assertOwned();
const identity = {
@@ -1303,7 +1341,7 @@ export function githubChatWizardService(
});
return resume(bot);
}
async function directCallback(state: string, code: string) {
async function directCallback(state: string, code: string, actor?: AuthorizationActor) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
@@ -1315,15 +1353,16 @@ export function githubChatWizardService(
session.trustedOrigin !== origin()
)
throw forbidden("GitHub return did not match this instance");
const managementContext = await authorizeBrowserReturn(session, actor);
const bot = await endpoint(session.endpointId, session.userId);
if (session.status === "failed" || session.status === "exchanging")
return resume(bot);
if (session.status === "pending") {
if (!options.completeDirect)
throw conflict("GitHub registration is unavailable");
await options.completeDirect(state, code);
await options.completeDirect(state, code, managementContext);
}
const progress = await advance(bot.id, session.userId);
const progress = await advance(bot.id, session.userId, managementContext);
return progress.state === "install" && progress.installationUrl
? progress.installationUrl
: resume(bot);
@@ -1332,6 +1371,7 @@ export function githubChatWizardService(
state: string,
registrationId: string,
claimId?: string,
actor?: AuthorizationActor,
) {
const [session] = await db
.select()
@@ -1342,6 +1382,7 @@ export function githubChatWizardService(
.limit(1);
if (!session?.handoff || !claimId)
throw forbidden("GitHub return did not match this instance");
const managementContext = await authorizeBrowserReturn(session, actor, session.handoff.identityStateHash === hash(state));
const cloud = connector();
if (!cloud) throw conflict("Restore Paperclip Cloud enrollment");
const opened = await cloud.claimGitHubApp({
@@ -1422,7 +1463,7 @@ export function githubChatWizardService(
});
if (opened.redirectUri !== route.callbackUrls.oauth)
throw forbidden("GitHub identity callback did not match");
return completeIdentity(session, opened.code, route.callbackUrls.oauth);
return completeIdentity(session, opened.code, route.callbackUrls.oauth, actor);
}
if (manifestReturn && session.status === "pending") {
await db
@@ -1437,12 +1478,12 @@ export function githubChatWizardService(
),
);
}
const progress = await advance(session.endpointId, session.userId);
const progress = await advance(session.endpointId, session.userId, managementContext);
return progress.state === "install" && progress.installationUrl
? progress.installationUrl
: resume(bot);
}
async function identityCallback(state: string, code: string) {
async function identityCallback(state: string, code: string, actor?: AuthorizationActor) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
@@ -1452,6 +1493,7 @@ export function githubChatWizardService(
.limit(1);
if (!session || session.trustedOrigin !== origin())
throw forbidden("GitHub identity return did not match");
await authorizeBrowserReturn(session, actor, true);
await endpoint(session.endpointId, session.userId);
if (!code) {
await db
@@ -1471,6 +1513,7 @@ export function githubChatWizardService(
session,
code,
`${origin()}/api/chat-github/identity/callback`,
actor,
);
}
async function confirmIdentity(
+95 -6
View File
@@ -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<RuntimeToolsTokenClaims, "sub" | "company_id" | "run_id" | "responsible_user_id">;
type SavedChatSetup = NonNullable<ConnectionIntentInteraction["payload"]["savedSetup"]>;
/** 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<Parameters<Db["transaction"]>[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<ConnectionIntentSetupOptions> {
const loaded = await loadIntent(interactionId);
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
if (payload.savedSetup) {
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, payload.savedSetup.endpointId), eq(chatEndpoints.companyId, loaded.issue.companyId), eq(chatEndpoints.connectionId, payload.savedSetup.connectionId)));
if (!endpoint || endpoint.assignedAgentId !== payload.requestingAgentId) throw notFound("Saved chat setup not found");
if (!loaded.interaction.addresseeUserId || endpoint.sponsorUserId !== loaded.interaction.addresseeUserId) throw notFound("Saved chat setup not found");
await connectionManagementService(db).authorize({ type: "board", userId: loaded.interaction.addresseeUserId, source: "session", ignoreInstanceAdmin: true }, "connections:configure", {
type: "connection_management", companyId: loaded.issue.companyId, connectionId: endpoint.connectionId,
targetAgentId: endpoint.assignedAgentId, purpose: "chat", operation: "chat.authentication_handoff",
});
const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, loaded.issue.companyId));
return { version: 1, interaction: loaded.interaction, requestedAgentId: payload.requestingAgentId, existingConnections: [],
service: { service: payload.serviceSlug, name: payload.serviceName, description: null, logoUrl: null, methods: [], state: "needs_user_action", connectionId: endpoint.connectionId },
chatSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId,
path: 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));
@@ -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<string, PermissionKey>;
export type ConnectionManagementAction = keyof typeof CONNECTION_MANAGEMENT_PERMISSIONS;
export function isConnectionManagementAction(action: string): action is ConnectionManagementAction {
return Object.hasOwn(CONNECTION_MANAGEMENT_PERMISSIONS, action);
}
/** Values come from resolved database rows, never caller-selected identities. */
export interface ConnectionManagementResource {
type: "connection_management";
companyId: string;
connectionId?: string | null;
profileId?: string | null;
gatewayId?: string | null;
provider?: string | null;
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[];
}
@@ -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<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId" | "responsibleUserIdOverride" | "authorization">;
reauthorize: (continuation?: { afterHumanAction: true }) => Promise<void>;
};
type CapturedConnectionRun = Omit<Awaited<ReturnType<typeof captureRunIdentity>>, "context"> & { context: Awaited<ReturnType<typeof captureRunIdentity>>["context"] | null };
const connectionReadRuns = new WeakMap<ConnectionManagementContext, CapturedConnectionRun>();
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<ConnectionManagementContext> {
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<string, unknown> | undefined)?.provider === "string" ? String((connection.config.ai as Record<string, unknown>).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<Parameters<typeof authorize>[3]> = {}): Promise<ConnectionManagementContext> {
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<typeof authorize>) {
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;
}
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "../../../connection-management.js";
import { notifyChatVerificationWork } from "../../../chat-work-notifications.js";
import { createAgentAvatarPool } from "../../../agent-avatar-pool.js";
import type { AgentAvatarRequest } from "../../../agent-avatars.js";
@@ -12,7 +13,7 @@ import { secretService } from "../../../secrets.js";
import { writeConnectionCredential } from "../../../connection-credentials.js";
import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js";
export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean }
export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean; managementContext?: ConnectionManagementContext }
export type Registration = typeof chatSlackRegistrations.$inferSelect;
@@ -59,6 +60,7 @@ export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions)
return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0];
}
async function endpoint(endpointId: string, actor: SlackSetupActor) {
await actor.managementContext?.reauthorize();
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance })
.from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId)))
@@ -76,13 +78,14 @@ export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions)
throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup.");
}
async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) {
await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId,
await logActivity(db, { companyId: row.companyId, ...(actor.managementContext?.audit ?? { actorType: "user" as const, actorId: actor.userId }),
action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId,
details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } });
}
const api = createSlackSetupClient(fetchImpl);
async function saveSecrets(row: Registration, values: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
patch: Partial<Registration> = {}, account?: SlackAccountState) {
await actor.managementContext?.reauthorize();
await db.transaction(async tx => {
await lease.assertOwned(tx);
const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0];
@@ -1,3 +1,4 @@
import { toolAccessPolicyService } from "../../tool-access-policy.js";
import { and, eq, gte, inArray, ne } from "drizzle-orm";
import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db";
import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared";
@@ -19,6 +20,7 @@ const human = (user: Record<string, unknown>, workspaceId: string) =>
export function slackTestMessageService(db: Db, options: Options) {
const api = createSlackSetupClient(options.fetch);
return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise<SlackTestMessageResult> => {
await actor.managementContext?.reauthorize();
const input = slackTestMessageSchema.parse(rawInput);
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled");
return options.withLock(endpointId, async lease => {
@@ -34,6 +36,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 };
});
@@ -1,5 +1,6 @@
import { writeLoginCleanup } from "./login-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -148,6 +149,7 @@ export interface LoginSessionRuntime {
* session, the company, the owner, and the adapter, so the promotion resolves
* the company slot and the sole-active-owner check for this exact session. */
export interface CredentialPromotionContext {
managementContext?: ConnectionManagementContext;
sessionId: string;
companyId: string;
startedByUserId: string;
@@ -210,6 +212,7 @@ export interface LoginSessionActivityEvent {
export type LoginSessionActivityRecorder = (event: LoginSessionActivityEvent) => void;
export interface StartDeviceLoginInput {
managementContext?: ConnectionManagementContext;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
environmentId: string;
@@ -915,6 +918,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
async function start(
input: StartDeviceLoginInput,
): Promise<StartDeviceLoginResult> {
await input.managementContext?.reauthorize();
const sessionId = randomUUID();
// The public session identifier the API returns and looks up. It is an
// independent CSPRNG value, so it never equals the internal `sessionId` and a
@@ -1124,7 +1128,9 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
if (!credential || credential.length === 0) {
throw new Error("missing_credential");
}
await input.managementContext?.reauthorize();
await profile.promotion.promote(credential, {
managementContext: input.managementContext,
sessionId,
companyId: input.companyId,
startedByUserId: input.startedByUserId,
@@ -1159,12 +1165,14 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
resultClaim: resultClaim ?? null,
});
const promotionContext: CredentialPromotionContext = {
managementContext: input.managementContext,
sessionId,
companyId: input.companyId,
startedByUserId: input.startedByUserId,
adapterType: input.adapterType,
};
try {
await input.managementContext?.reauthorize();
return profile.promotion.runTerminalCommit
? await profile.promotion.runTerminalCommit(commitAuthenticated, promotionContext)
: await commitAuthenticated();
+16
View File
@@ -1,3 +1,4 @@
import type { ConnectionManagementContext } from "./connection-management.js";
import { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { fastResponseService, enqueueFastResponse, fastResponseSourceCurrent, fastResponseTurnQueued } from "./fast-responses.js";
@@ -72,6 +73,7 @@ import {
} from "./agentmail-api.js";
export type EmailActor = {
managementContext?: ConnectionManagementContext;
userId?: string;
agentId?: string;
runId?: string;
@@ -277,6 +279,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
companyId: endpoint.companyId,
actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system",
actorId: actor.agentId ?? actor.userId ?? "agentmail",
...actor.managementContext?.audit,
action,
entityType: "tool_connection",
entityId: endpoint.connectionId,
@@ -627,6 +630,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
input: EmailEndpointSetupInput,
actor: EmailActor,
) {
await actor.managementContext?.reauthorize();
const [agent] = await db
.select()
.from(agents)
@@ -684,6 +688,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
)
throw conflict("Setup request already belongs to another inbox");
if (!endpoint) {
await actor.managementContext?.reauthorize();
const applicationId = input.applicationId ?? randomUUID();
const connectionId = randomUUID();
await db.transaction(async (tx) => {
@@ -725,6 +730,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
authKind: "api_key",
ownership: "customer",
credentialPolicy: "shared",
createdByUserId: actor.userId ?? null,
status: "draft",
enabled: false,
config: { provider: "agentmail" },
@@ -831,6 +837,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
});
if (scope.scope_type === "inbox" && scope.inbox_id !== inbox.inbox_id)
throw forbidden("API key belongs to a different inbox");
await actor.managementContext?.reauthorize();
await db
.update(chatEndpoints)
.set({
@@ -851,6 +858,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
if (!config.ownedApiKeyId && scope.scope_type !== "inbox") {
const key = await api.createInboxKey(inbox.inbox_id);
try {
await actor.managementContext?.reauthorize();
await vault(endpoint, "apiKey", key.api_key);
await db
.update(emailEndpoints)
@@ -881,6 +889,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
);
const webhook = await createWebhook({ ...endpoint, botExternalId: inbox.inbox_id }, api);
try {
await actor.managementContext?.reauthorize();
await vault(endpoint, "webhookSecret", webhook.secret);
await db
.update(emailEndpoints)
@@ -891,6 +900,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
throw error;
}
}
await actor.managementContext?.reauthorize();
const now = new Date();
await db.transaction(async (tx) => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.email);
@@ -2375,6 +2385,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
action: "pause" | "resume" | "remove",
actor: EmailActor,
) {
await actor.managementContext?.reauthorize();
const endpoint = await getEndpoint(id);
const result = await withLease(endpoint, async () => {
const config = await getConfig(id);
@@ -2387,6 +2398,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
endpoint.botExternalId!,
);
}
await actor.managementContext?.reauthorize();
if (action !== "resume") await stopEndpoint(endpoint);
let cleanupError: string | null = null;
if (action === "remove") {
@@ -2477,6 +2489,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
receiveMode: "websocket" | "webhook",
actor: EmailActor,
) {
await actor.managementContext?.reauthorize();
const endpoint = await getEndpoint(id);
if (endpoint.status === "archived" || !endpoint.botExternalId)
throw conflict("Create a new inbox connection");
@@ -2488,6 +2501,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
)
throw forbidden("API key belongs to a different inbox");
await api.getInbox(endpoint.botExternalId);
await actor.managementContext?.reauthorize();
const result = await withLease(endpoint, async () => {
const config = await getConfig(id);
// Check registration permissions before interrupting a working socket.
@@ -2496,6 +2510,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
: null;
if (preparedWebhook) {
try {
await actor.managementContext?.reauthorize();
await vault(endpoint, "webhookSecret", preparedWebhook.secret);
await db.update(emailEndpoints).set({ webhookId: preparedWebhook.webhook_id })
.where(eq(emailEndpoints.endpointId, id));
@@ -2504,6 +2519,7 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
throw error;
}
}
await actor.managementContext?.reauthorize();
await stopEndpoint(endpoint);
// Only registrations and scoped keys created by Paperclip are removed.
if (config.webhookId)
+25 -10
View File
@@ -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<EmailCredentialOption[]> {
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,
@@ -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,
+11 -7
View File
@@ -1,4 +1,5 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import type { ConnectionManagementContext } from "./connection-management.js";
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
@@ -83,10 +84,11 @@ export function localAiLoginService(db: Db) {
}
}
async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false): Promise<LocalAiLoginAttempt> {
async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false, managementContext?: ConnectionManagementContext): Promise<LocalAiLoginAttempt> {
if (intent.provider !== "openai" && intent.provider !== "xai" && intent.provider !== "anthropic")
throw unprocessable("This provider does not use a separate local login home.");
await reapExpired();
await managementContext?.reauthorize();
const attempt = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-local-login:${companyId}:${userId}:${intent.provider}`}, 0))`);
const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local";
@@ -109,7 +111,7 @@ export function localAiLoginService(db: Db) {
await tx.update(adapterAuthSessions).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(eq(adapterAuthSessions.id, existing.id));
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled",
companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_cancelled",
entityType: "adapter_auth_session", entityId: existing.id, details: { provider: intent.provider },
});
}
@@ -129,7 +131,7 @@ export function localAiLoginService(db: Db) {
connectionMethod: LOCAL_LOGIN_METHOD, status: "waiting_for_user", expiresAt,
});
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_started",
companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_started",
entityType: "adapter_auth_session", entityId: id, details: { provider: intent.provider },
});
} catch (error) {
@@ -201,7 +203,8 @@ export function localAiLoginService(db: Db) {
});
}
async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent) {
async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
const result = await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
@@ -222,7 +225,7 @@ export function localAiLoginService(db: Db) {
// Nested transaction is a savepoint on this same connection. Holding the
// attempt lock makes completion/cancellation/restart retries idempotent.
const saved = await aiConnectionService(tx as unknown as Db)
.save(companyId, userId, intent, credential, { id }, session.createdAt);
.save(companyId, userId, intent, credential, { id }, session.createdAt, managementContext);
await tx.update(adapterAuthSessions).set({
status: "authenticated", connectionMethod: LOCAL_LOGIN_METHOD,
finishedAt: new Date(), updatedAt: new Date(),
@@ -235,7 +238,8 @@ export function localAiLoginService(db: Db) {
return result;
}
async function cancel(companyId: string, userId: string, id: string) {
async function cancel(companyId: string, userId: string, id: string, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
@@ -250,7 +254,7 @@ export function localAiLoginService(db: Db) {
status: "cancelled", finishedAt: new Date(), updatedAt: new Date(),
}).where(eq(adapterAuthSessions.id, id));
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled",
companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "ai_connection.local_login_cancelled",
entityType: "adapter_auth_session", entityId: id,
details: { provider: session.aiConnection?.provider },
});
@@ -89,9 +89,9 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
const protocol = operation["x-paperclip-transport"] === "protocol" || (operation["x-paperclip-transport"] !== "rest" && (!path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
|| /event-stream|websocket/i.test(JSON.stringify(operation.responses));
|| /event-stream|websocket/i.test(JSON.stringify(operation.responses))));
result.push({
operationId: `${method} ${path}`, method, path,
summary: operation.summary ?? `${method} ${path}`,
@@ -114,7 +114,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions."
: "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.",
allowedModes: method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
allowedModes: operation["x-paperclip-connection-management"] ? (method === "GET" ? ["standard", "ask", "planning"] : ["standard"]) : method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
@@ -1,4 +1,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)
@@ -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<typeof fetch>(async () => Response.json({ error: "Route permission required" }, { status: 403 }));
expect(runnerApiOperation(operationId).transport).toBe("rest");
const result = await executeRunnerApi({ operationId, pathParams: operationId.includes("{type}") ? { type: "codex_local" } : { connectionId: "connection-a" }, body: {} }, context, io(request));
expect(result).toMatchObject({ status: 403 });
expect(request).toHaveBeenCalledOnce();
});
it.each(
runnerApiCatalog().filter(
(operation) =>
@@ -1,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
@@ -30,5 +30,5 @@ export async function completeSetupTokenAiConnection(
});
if (!transitioned) throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED);
},
});
}, undefined, scope.managementContext);
}
@@ -1,5 +1,6 @@
import { writeLoginCleanup } from "./login-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import type { ConnectionManagementContext } from "./connection-management.js";
// The Claude setup-token login session service. It owns a company-scoped,
// owner-bound login session that holds one live setup-token process and one
// sandbox lease for the whole login. The login is a two-way round-trip: the
@@ -111,6 +112,7 @@ export const SETUP_TOKEN_CANCELLABLE_STATES: readonly SetupTokenSessionState[] =
* per environment.
*/
export interface SetupTokenSessionScope {
managementContext?: ConnectionManagementContext;
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
ownerUserId: string;
+47 -13
View File
@@ -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<string, unknown> | 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<ToolAccessDecision> {
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<ToolAccessDecision> {
// 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();
}
+43 -24
View File
@@ -1,3 +1,5 @@
import type { ConnectionManagementContext } from "./connection-management.js";
import { connectionActorUserId } from "./tool-access/actor.js";
import { restConnectorFor } from "./connectors/rest-registry.js";
import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js";
import { createConnectionGrants } from "./tool-access/connections/grants.js";
@@ -1421,7 +1423,7 @@ export function toolAccessService(
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
connectionActorUserId(actor),
})
.returning();
return toProfileBinding(created);
@@ -1594,6 +1596,7 @@ export function toolAccessService(
input: ConnectToolApp,
actor?: ActorInfo,
): Promise<ConnectToolAppResult> {
await actor?.managementContext?.reauthorize();
const definition = input.galleryKey
? getConnectableAppDefinition(input.galleryKey)
: null;
@@ -1880,10 +1883,10 @@ export function toolAccessService(
previousGrantKind &&
input.grantKind &&
previousGrantKind !== input.grantKind &&
actor?.actorType === "user" &&
actor.actorSource !== "local_implicit"
Boolean(connectionActorUserId(actor)) &&
actor?.actorSource !== "local_implicit"
) {
const actorUserId = actor.actorId;
const actorUserId = connectionActorUserId(actor);
const [membership] = actorUserId
? await db
.select({
@@ -2203,15 +2206,9 @@ export function toolAccessService(
const credentialRefs: McpConnectionCredentialRef[] = [];
const createdSecretIds: string[] = [];
const createdDefinitionIds: string[] = [];
// "Just me" needs a named board user to own the consent. An agent actor
// cannot hold a personal identity, and silently falling back to a shared
// credential is exactly the mis-scoping the design forbids, so refuse.
const personalIdentityUserId =
requestedGrantKind === "user"
? actor?.actorType === "user" && actor.actorId
? actor.actorId
: null
: null;
// Personal consent belongs to the authenticated responsible user. A delegated
// agent is recorded separately and never becomes the account owner.
const personalIdentityUserId = requestedGrantKind === "user" ? connectionActorUserId(actor) : null;
if (requestedGrantKind === "user" && !personalIdentityUserId) {
throw badRequest(
"Connecting an app as yourself requires a signed-in user",
@@ -2535,7 +2532,7 @@ export function toolAccessService(
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
connectionActorUserId(actor),
})
.returning();
}
@@ -3123,6 +3120,7 @@ export function toolAccessService(
input: FinishToolApp,
actor?: ActorInfo,
): Promise<FinishToolAppResult> {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
await assertConnectorConnectionSetupEnabled(connection);
if (connection.status === "archived")
@@ -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<string, string> },
actor?: ActorInfo,
): Promise<ToolConnectionHealthCheckResult> {
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<ToolStdioCommandTemplate> => {
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<ToolApplication> => {
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<ToolApplication> => {
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<ToolApplication> => {
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<ToolConnection> => {
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<ToolConnection> => {
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<RailwaySshSetup | null> => {
await actor.managementContext?.reauthorize();
const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : "";
const setup = await db.transaction(async (tx) => {
const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update");
const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update");
if (!connection || !isRailwayConnection(connection) || (connection.status !== "active" && input.action !== "remove")) throw unprocessable("Connect Railway before configuring container access.");
if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization.");
if (grant.kind === "user" && (actor.actorType !== "user" || actor.actorId !== grant.subjectUserId)) throw forbidden("Only this authorization's owner can configure its SSH key.");
if (grant.kind === "user" && connectionActorUserId(actor) !== grant.subjectUserId) throw forbidden("Only this authorization's owner can configure its SSH key.");
const existing = asRecord(connection.config.railwaySsh);
if (existing.grantId && existing.grantId !== grant.id) throw conflict("Remove the existing container key before choosing another authorization.");
const currentRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH);
@@ -1,3 +1,5 @@
import { connectionManagementService } from "../../connection-management.js";
import type { ConnectionManagementContext } from "../../connection-management.js";
import { and, desc, eq, gte, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, connectionTokenIssuances, authUsers, heartbeatRuns, issues, toolActionRequests, toolConnections, toolCallEvents, toolInvocations } from "@paperclipai/db";
@@ -92,10 +94,11 @@ listConnectionActivity: async (
connectionId: string,
companyId?: string,
limit = 20,
managementContext?: ConnectionManagementContext,
): Promise<ToolConnectionActivityResponse> => {
const connection = await getConnectionRow(connectionId, companyId);
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
const rows = await db
let rows = await db
.select()
.from(toolCallEvents)
.where(
@@ -106,6 +109,22 @@ listConnectionActivity: async (
)
.orderBy(desc(toolCallEvents.createdAt))
.limit(safeLimit);
if (managementContext?.actor.type === "agent") {
const management = connectionManagementService(db);
const taskVisibility = new Map<string | null, boolean>();
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 = [
+4
View File
@@ -10,3 +10,7 @@ export function actorBinding(actor: ActorInfo | undefined) {
: null,
};
}
export function connectionActorUserId(actor?: ActorInfo): string | null {
return actor?.actorType === "user" ? actor.actorId ?? null : actor?.managementContext?.responsibleUserId ?? null;
}
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import { actorForSecret } from "../oauth/credential-store.js";
import type { ActorInfo } from "../types.js";
import { remoteEndpoint, projectedConnectionHeaders } from "../connection-methods.js";
@@ -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<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
@@ -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<AggregatorAppsResponse["discovery"]> {
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<AggregatorAppsResponse> {
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<AggregatorAppsResponse> {
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) };
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import { assertSupportedConnection } from "../connections/setup-policy.js";
import { connectionSetupUrl, connectionReconnectUrl } from "../identifiers.js";
import { createGitHubGrantContinuity } from "../github-grant-continuity.js";
@@ -100,16 +101,16 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
),
);
const actorGrant =
actor?.actorType === "user"
connectionActorUserId(actor)
? activeGrants.find(
(grant) =>
grant.kind === "user" &&
grant.subjectUserId === actor.actorId,
grant.subjectUserId === connectionActorUserId(actor),
)
: null;
const grantsToCheck =
connection.credentialPolicy === "per_user" &&
actor?.actorType === "user"
Boolean(connectionActorUserId(actor))
? actorGrant
? [actorGrant]
: []
@@ -132,8 +133,8 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d
connection.status === "draft" &&
connection.authKind === "none" &&
connection.credentialPolicy === "per_user" &&
actor?.actorType === "user" &&
actor.actorId === connection.createdByUserId;
Boolean(connectionActorUserId(actor)) &&
connectionActorUserId(actor) === connection.createdByUserId;
const credentialHeaders =
connection.credentialSource === "vercel_connect"
? await resolveCredentialHeaders(connection, actor, {
@@ -58,6 +58,7 @@ export function createToolCatalogPublication(db: Db, { now, audit, getConnection
credentialHeaders?: Record<string, string>;
} = {},
): Promise<ToolCatalogRefreshResult> {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId);
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog");
await assertConnectorConnectionSetupEnabled(connection);
@@ -1,3 +1,5 @@
import type { ConnectionManagementContext } from "../../connection-management.js";
import { connectionActorUserId } from "../actor.js";
import { toConnectionGrant } from "./projection.js";
import { actorBinding } from "../actor.js";
import { vercelConnectResourcesFor } from "../vercel-credentials.js";
@@ -125,6 +127,7 @@ replaceConnectionGrantMembers: async (
memberUserIds: string[],
actor?: ActorInfo,
) => {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
const [grant] = await db
.select()
@@ -278,7 +281,9 @@ replaceConnectionGrantMembers: async (
grantId: string,
agentId: string,
ownerUserId: string,
managementContext?: ConnectionManagementContext,
) => {
await managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
if (connection.connectionPurpose === "ai") {
throw badRequest("AI credentials use the connection's human access settings, not agent delegation");
@@ -363,12 +368,13 @@ replaceConnectionGrantMembers: async (
await tx.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: "user",
actorId: ownerUserId,
actorType: managementContext?.audit.actorType ?? "user",
actorId: managementContext?.audit.actorId ?? ownerUserId,
action: "connection_grant.delegated",
outcome: "success",
reasonCode: "delegation_created",
details: { grantId: grant.id, delegationId: delegation!.id, agentId },
details: { grantId: grant.id, delegationId: delegation!.id, agentId,
...(managementContext ? { responsibleUserId: managementContext.responsibleUserId, runId: managementContext.actor.runId, agentApiKeyId: managementContext.actor.keyId } : {}) },
});
return delegation!;
});
@@ -379,6 +385,7 @@ replaceConnectionGrantMembers: async (
delegationId: string,
actor?: ActorInfo,
) => {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
const delegation = await db.transaction(async tx => {
const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants)
@@ -425,6 +432,7 @@ replaceConnectionGrantMembers: async (
},
actor?: ActorInfo,
) => {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
await assertSecretRefs(
connection.companyId,
@@ -454,8 +462,7 @@ replaceConnectionGrantMembers: async (
isDefault: input.isDefault ?? false,
createdByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
createdByUserId:
binding.actorType === "user" ? binding.actorId : null,
createdByUserId: connectionActorUserId(actor),
})
.returning();
if (!grant) throw new Error("Failed to create connection installation");
@@ -480,6 +487,7 @@ replaceConnectionGrantMembers: async (
grantId: string,
actor?: ActorInfo,
) => {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(idOrUid);
const binding = actorBinding(actor);
const [currentGrant] = await db
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import { toConnection, toConnectionInstall } from "./projection.js";
import { actorBinding } from "../actor.js";
import { lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "../assignment-locks.js";
@@ -147,6 +148,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
input: PutToolConnectionInstalls,
actor?: ActorInfo,
): Promise<ToolConnectionInstallSnapshot> => {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId);
const requested = new Map(
input.installs.map((install) => [
@@ -267,7 +269,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
connectionActorUserId(actor),
})),
);
}
@@ -289,7 +291,7 @@ export function createConnectionInstalls(db: Db, { now, getConnectionRow, assert
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
connectionActorUserId(actor),
})
.onConflictDoNothing()
.returning({ id: toolProfileBindings.id });
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import type { ActorInfo } from "../types.js";
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -47,7 +48,7 @@ async function fixedPersonalIdentityForReconnect(
const fixedSubjectUserId =
connection.createdByUserId ?? retainedGrant?.subjectUserId ?? null;
const binding = actorBinding(actor);
const actorUserId = binding.actorType === "user" ? binding.actorId : null;
const actorUserId = connectionActorUserId(actor);
const subjectUserId = requestedSubjectUserId ?? actorUserId;
if (!subjectUserId) {
@@ -228,6 +228,7 @@ async function removeConnection(
companyId?: string,
actor?: ActorInfo,
): Promise<ToolConnectionRemovalResult> {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack")));
if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor);
@@ -243,6 +244,7 @@ async function removeConnection(
}
async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise<ToolConnectionRemovalResult> {
await actor?.managementContext?.reauthorize();
const connection = await getConnectionRow(connectionId, companyId);
forgetMcpHttpSessions(connection.id);
const now = new Date();
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "./actor.js";
import type { ActorInfo } from "./types.js";
import { oauthConfig } from "./oauth/metadata.js";
import { remoteEndpoint } from "./connection-methods.js";
@@ -39,7 +40,7 @@ export function createConnectionCredentialAccess(db: Db, { secrets, audit, verce
actor?: ActorInfo,
): Promise<typeof connectionGrants.$inferSelect | null> {
const actorUserId =
actor?.actorType === "user" ? (actor.actorId ?? null) : null;
connectionActorUserId(actor);
if (actorUserId) {
const [personal] = await db
.select()
@@ -30,6 +30,7 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector
initialGrant: typeof connectionGrants.$inferSelect,
actor?: ActorInfo,
) {
await actor?.managementContext?.reauthorize();
return measureRunPhase("connectors.github.refresh", async () => {
try {
return await refreshManagedGitHubGrantAccessOnce(
@@ -64,6 +65,7 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector
initialGrant: typeof connectionGrants.$inferSelect,
actor?: ActorInfo,
) {
await actor?.managementContext?.reauthorize();
let grant = await measureRunPhase("connectors.github.credentials", () => refreshOAuthGrantCredentials({
companyId: connection.companyId,
connectionId: connection.id,
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import type { ActorInfo } from "../types.js";
import { connectionMethodForConnection } from "../connection-methods.js";
import { emitConnectionUpdated } from "../../connector-telemetry.js";
@@ -38,6 +39,7 @@ async function finalizeOAuthAccess(
actor?: ActorInfo,
requestingAgentId?: string,
): Promise<FinishToolAppResult> {
await actor?.managementContext?.reauthorize();
if (requestingAgentId)
await assertAgentsInCompany(companyId, [requestingAgentId]);
let connection = await getConnectionRow(connectionId, companyId);
@@ -49,7 +51,7 @@ async function finalizeOAuthAccess(
throw badRequest("This connection does not use browser sign-in");
if (connection.status === "archived")
throw conflict("Archived app connections cannot be finished");
const actorUserId = actor?.actorType === "user" ? actor.actorId : null;
const actorUserId = connectionActorUserId(actor);
if (!actorUserId)
throw badRequest("Finishing browser sign-in requires a signed-in user");
@@ -9,10 +9,10 @@ import { connectionMethodForConnection } from "../connection-methods.js";
import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared";
import { and, eq, lt } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db";
import { companies, agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db";
import type { ConnectionGrantKind, ToolOAuthStartResult } from "@paperclipai/shared";
import { getConnectableAppDefinition } from "@paperclipai/shared";
import { badRequest, conflict, forbidden, unprocessable } from "../../../errors.js";
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../errors.js";
import { isPaperclipCloudConnectorStrategy, type PaperclipCloudConnector } from "../../paperclip-cloud-connector.js";
import { deriveVercelConnectSubject, vercelConnectCallbackUrl, VercelConnectClientError, type VercelConnectClient } from "../../vercel-connect.js";
@@ -53,6 +53,7 @@ export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies
interactionId?: string;
},
): Promise<ToolOAuthStartResult> {
await input.actor.managementContext?.reauthorize();
let connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertConnectorConnectionSetupEnabled(connection);
@@ -170,13 +171,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<string, unknown> : {};
const prior = oauth.pendingWorkspaceServiceRequests;
const pending = prior && typeof prior === "object" && !Array.isArray(prior) ? prior as Record<string, unknown> : {};
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(
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import type { ActorInfo } from "../types.js";
import { isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { and, eq } from "drizzle-orm";
@@ -138,9 +139,7 @@ export function createOAuthCatalogCompletion(db: Db, finishGalleryAppConnection:
? (input.actor.actorId ?? null)
: null,
createdByUserId:
input.actor?.actorType === "user"
? (input.actor.actorId ?? null)
: null,
connectionActorUserId(input.actor),
})),
)
.onConflictDoNothing();
@@ -366,6 +366,7 @@ const oauthRefreshFlights = new Map<string, Promise<unknown>>();
heartbeatRunId?: string | null;
},
): Promise<typeof toolConnections.$inferSelect> {
await actor?.managementContext?.reauthorize();
const oauth = oauthConfig(connection);
if (
typeof oauth.tokenUrl !== "string" ||
@@ -1,3 +1,4 @@
import { connectionActorUserId } from "../actor.js";
import type { ActorInfo, DbTransaction, ToolAccessMutationDb } from "../types.js";
import { oauthConfig } from "./metadata.js";
import { syncConnectionCredentialBindings } from "../../connection-credential-bindings.js";
@@ -80,8 +81,7 @@ async function syncCredentialBindings(
let credentialSecretRefs = connection.credentialSecretRefs;
if (
connection.credentialPolicy === "per_user" &&
actor?.actorType === "user" &&
actor.actorId &&
Boolean(connectionActorUserId(actor)) &&
!credentialSecretRefs.some(
(ref) => ref.configPath === "oauth.client_secret",
)
@@ -96,7 +96,7 @@ async function syncCredentialBindings(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, actor.actorId),
eq(connectionGrants.subjectUserId, connectionActorUserId(actor)!),
eq(connectionGrants.status, "active"),
),
)
@@ -1,3 +1,5 @@
import type { ConnectionManagementContext } from "../../connection-management.js";
import { connectionActorUserId } from "../actor.js";
import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js";
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
@@ -156,6 +158,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
input: ReviewToolProfileNewTools,
actor?: ActorInfo,
): Promise<ToolProfileNewToolsReviewResult> {
await actor?.managementContext?.reauthorize();
const profile = await getProfileRow(profileId);
const review = await listProfileNewTools(profile.id, profile.companyId);
if (review.tools.length === 0)
@@ -213,7 +216,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
reviewedByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
reviewedByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
connectionActorUserId(actor),
updatedAt: nowAt,
})
.where(
@@ -460,8 +463,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
createProfile: async (
companyId: string,
input: CreateToolProfileWithEntries,
input: CreateToolProfileWithEntries, managementContext?: ConnectionManagementContext,
): Promise<ToolProfileWithDetails> => {
await managementContext?.reauthorize();
await assertProfileEntryInputs(db, companyId, input.entries ?? []);
const [row] = await db
.insert(toolProfiles)
@@ -487,8 +491,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
updateProfile: async (
profileId: string,
input: UpdateToolProfileWithEntries,
input: UpdateToolProfileWithEntries, managementContext?: ConnectionManagementContext,
): Promise<ToolProfileWithDetails> => {
await managementContext?.reauthorize();
const existing = await getProfileRow(profileId);
if (input.entries) {
await assertProfileEntryInputs(db, existing.companyId, input.entries);
@@ -538,8 +543,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
duplicateProfile: async (
profileId: string,
input: DuplicateToolProfile,
input: DuplicateToolProfile, managementContext?: ConnectionManagementContext,
): Promise<ToolProfileWithDetails> => {
await managementContext?.reauthorize();
const existing = await getProfileRow(profileId);
const [entries, bindings] = await Promise.all([
db
@@ -614,13 +620,14 @@ export function createToolProfileService(db: Db, now: () => Date) {
deleteProfile: async (
profileId: string,
input: DeleteToolProfile,
input: DeleteToolProfile, managementContext?: ConnectionManagementContext,
): Promise<{
profile: ToolProfile;
summary: ToolProfileSummary;
reassignedToProfileId: string | null;
reassignedBindingCount: number;
}> => db.transaction(async tx => {
await managementContext?.reauthorize();
const existing = await getProfileRow(profileId, undefined, tx);
const db = tx as unknown as Db;
await tx.select({ id: toolProfiles.id }).from(toolProfiles)
@@ -715,8 +722,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
addProfileEntry: async (
profileId: string,
input: CreateToolProfileEntryForProfile,
input: CreateToolProfileEntryForProfile, managementContext?: ConnectionManagementContext,
): Promise<ToolProfileEntry> => db.transaction(async tx => {
await managementContext?.reauthorize();
const db = tx as unknown as Db;
const profile = await getProfileRow(profileId, undefined, tx);
await assertProfileEntryInputs(tx, profile.companyId, [input]);
@@ -755,8 +763,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
updateProfileEntry: async (
entryId: string,
input: UpdateToolProfileEntry,
input: UpdateToolProfileEntry, managementContext?: ConnectionManagementContext,
): Promise<ToolProfileEntry> => db.transaction(async tx => {
await managementContext?.reauthorize();
const db = tx as unknown as Db;
const [existing] = await db
.select()
@@ -798,7 +807,8 @@ export function createToolProfileService(db: Db, now: () => Date) {
return toProfileEntry(row);
}),
deleteProfileEntry: async (entryId: string): Promise<ToolProfileEntry> => db.transaction(async tx => {
deleteProfileEntry: async (entryId: string, managementContext?: ConnectionManagementContext): Promise<ToolProfileEntry> => db.transaction(async tx => {
await managementContext?.reauthorize();
const db = tx as unknown as Db;
const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId));
if (!existing) throw notFound("Tool profile entry not found");
@@ -820,6 +830,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
input: CreateToolProfileBindingForProfile,
actor?: ActorInfo,
): Promise<ToolProfileBinding> => db.transaction(async tx => {
await actor?.managementContext?.reauthorize();
const db = tx as unknown as Db;
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!profile) throw notFound("Tool profile not found");
@@ -841,7 +852,7 @@ export function createToolProfileService(db: Db, now: () => Date) {
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
connectionActorUserId(actor),
})
.returning();
await db
@@ -853,8 +864,9 @@ export function createToolProfileService(db: Db, now: () => Date) {
unbindProfile: async (
profileId: string,
input: UnbindToolProfileBinding,
input: UnbindToolProfileBinding, managementContext?: ConnectionManagementContext,
): Promise<{ unbound: number }> => db.transaction(async tx => {
await managementContext?.reauthorize();
const db = tx as unknown as Db;
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!profile) throw notFound("Tool profile not found");
@@ -114,6 +114,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick<ReturnTyp
action: "stop" | "restart";
actor?: ActorInfo;
}): Promise<ToolRuntimeSlot> {
await input.actor?.managementContext?.reauthorize();
try {
if (input.action === "stop") {
await runtimeSupervisor.stopSlot({
@@ -132,6 +133,7 @@ export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick<ReturnTyp
companyId: input.companyId,
actorType: input.actor?.actorType ?? "system",
actorId: input.actor?.actorId ?? "tool-access-service",
...input.actor?.managementContext?.audit,
action:
input.action === "stop"
? "tool_runtime_slot.operator_stopped"
+4
View File
@@ -1,6 +1,9 @@
import type { ConnectionManagementContext } from "../connection-management.js";
import type { Db } from "@paperclipai/db";
export type ActorInfo = {
managementContext?: ConnectionManagementContext;
responsibleUserId?: string | null;
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
sessionId?: string | null;
@@ -14,6 +17,7 @@ export type ActorInfo = {
| "cloud_tenant";
};
export type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type ToolAccessMutationDb = Pick<
+1 -1
View File
@@ -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())
+312 -48
View File
@@ -4,6 +4,7 @@ import { promptInjectionPreventionService } from "./prompt-injection-prevention.
import { adapterExecutionControls } from "./adapter-execution-control.js";
import type { BudgetServiceHooks } from "./budgets.js";
import type { PromptInjectionDetectorMetadata } from "@paperclipai/shared";
import { connectionManagementService, type ConnectionManagementContext } from "./connection-management.js";
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js";
import { nativeProviderCatalogFor } from "./native-provider-catalogs.js";
@@ -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<ToolAccessDecisionCache, ToolAccessDecisionCache>();
const policyService = {
...basePolicyService,
async decide(input: ToolAccessDecisionInput, decisionOptions: NonNullable<Parameters<typeof basePolicyService.decide>[1]> & { requiredGatewayAction?: ToolMcpGatewayTokenAction } = {}): Promise<ToolAccessDecision> {
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<void> {
const agentId = invocation.agentId;
if (!invocation.connectionId || !agentId) return;
const userId = invocation.actorId ?? "board";
const delegated = invocation.policyExplanation?.delegatedConnectionTest as {
agentId: string; responsibleUserId: string; runId: string | null; keyId: string | null;
resource: ConnectionManagementContext["resource"];
} | undefined;
const userId = delegated?.responsibleUserId ?? invocation.actorId ?? "board";
const session: ToolGatewaySession = {
id: "test-call",
token: "test-call",
@@ -7667,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<ToolMcpGatewayWithTokens> {
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<ToolMcpGatewayWithTokens> {
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<ToolMcpGatewayTokenCreated> {
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<ToolMcpGatewayToken> {
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<ToolConnectionTestCallStatus> {
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(