Files
PaperClipAI/packages/db/src/migrations/0276_hard_mandroid.sql
T
DottaandPaperclip 47ded8bf97 feat: manage AI runtime credentials through Connections (#13247)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs need credentials for a specific provider and sign-in
method.
> - Connections already owns accounts, grants, and access permissions.
> - AI authentication should use those same boundaries.
> - This pull request adds the storage, API, adoption, and runtime
foundation.
> - Legacy agents keep their authentication until they explicitly adopt
a managed connection.

## Linked Issues or Issue Description

**Problem or motivation**
AI credentials are configured separately from Connections. Agents cannot
consistently reuse a responsible user's account or a permitted shared
account.

**Proposed solution**
Manage AI accounts with the existing Connections grants and permissions.
Keep model and harness selection independent from credential selection.
Preserve legacy authentication until validated adoption.

**Alternatives considered**
A separate credential registry would duplicate ownership and access
policy. Automatic fallback would risk using the wrong account.

**Roadmap alignment**
This extends the shipped Apps, multi-user, secrets, and agent-runtime
capabilities. The maintainer requested the feature and reviewed the UI.
Related groundwork: #11899 (connection permissions), #10910 (connection
wizard), #11692 (Claude subscription profiles), and #11854 (Codex
account rotation).

## What Changed

- Add AI-purpose/runtime-auth contracts and an additive, idempotent
migration.
- Add Claude, OpenAI, OpenRouter, and Grok provider capabilities and
catalog entries.
- Store credentials on grants. Resolve responsible-user defaults or
explicit permitted grants.
- Isolate managed credentials and provider sessions across accounts.
Block missing credentials without ambient fallback.
- Keep imported legacy secrets unchanged during reconnect. Use
independent local Codex/Grok sign-in attempts for rotating credentials.
- Add authorization, migration, concurrent refresh, retry, cancellation,
and legacy-compatibility tests.

This is part 1 of a two-PR stack. The app UI follows in #13248. Merge
the foundation first.

## Verification

- Updated against master `04e364236`, preserving upstream provider login
and connector workflows.
- Full workspace typecheck, production build, Storybook build, and token
gates passed on the integrated branch. Final local-login changes passed
59 focused tests; new-agent and inbox regression suites passed 63 tests.
- Browser checks verified automatic local Claude account detection,
resumable Codex login commands, retry, focus restoration, and
desktop/phone layouts. Commands create their isolated directory before
invoking the CLI.
- All current-head CI checks passed on `2a996560a`, including all
server/workspace tests, browser shards, runner verification, typecheck,
build, and canary dry run. Greptile reviewed that commit at 5/5 with no
unresolved threads. Earlier local full-suite attempts hit the Mac
PostgreSQL shared-memory limit; the complete suites passed in CI.
- Renumbered the additive AI migration to `0276` after upstream
migrations and regenerated its snapshot. Existing legacy agents retain
their configuration.
- Added local login status checks, owner-scoped retry, managed OpenCode
remote homes, credential-aware model discovery, and task
connection-repair delivery.

## Risks

- Managed credential failures intentionally block execution. They do not
restore legacy fallback.
- Preview-era copied Codex/Grok subscriptions require independent
reconnect.
- The integrated branch has live provider acceptance coverage. This
update verifies local Claude detection and Codex API-key task repair; it
does not add a new subscription authorization/refresh or Daytona stress
pass.
- Runtime-auth connections must stay excluded from tool and channel
handling.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, code
execution, and browser testing. The exact runtime model identifier and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-12 16:30:10 -05:00

78 lines
7.5 KiB
SQL

CREATE TABLE IF NOT EXISTS "ai_connection_defaults" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"method" text NOT NULL,
"grant_id" uuid,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ai_connection_defaults_provider_check" CHECK ("ai_connection_defaults"."provider" in ('anthropic','openai','openrouter','xai')),
CONSTRAINT "ai_connection_defaults_method_check" CHECK ("ai_connection_defaults"."method" in ('subscription','api_key'))
);
--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_transport_check";--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_purpose_check";--> statement-breakpoint
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_channel_transport_check";--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "ai_connection" jsonb;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "connection_id" uuid;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "connection_grant_id" uuid;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN IF NOT EXISTS "connection_method" text;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "ai_connection_defaults" ADD CONSTRAINT "ai_connection_defaults_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ai_connection_defaults_owner_method_uq" ON "ai_connection_defaults" USING btree ("company_id","user_id","provider","method");--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_transport_check" CHECK ("tool_connections"."transport" in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk', 'runtime_auth'));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_purpose_check" CHECK ("tool_connections"."connection_purpose" in ('tool', 'channel', 'ai'));--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_channel_transport_check" CHECK ((
("tool_connections"."connection_purpose" = 'tool' and "tool_connections"."transport" not in ('chat_sdk', 'runtime_auth'))
or
("tool_connections"."connection_purpose" = 'channel' and ("tool_connections"."transport" = 'chat_sdk' or ("tool_connections"."transport" = 'rest_api' and "tool_connections"."config"->>'provider' = 'agentmail')))
or
("tool_connections"."connection_purpose" = 'ai' and "tool_connections"."transport" = 'runtime_auth')
));--> statement-breakpoint
-- Only declared per-user credentials have reliable ownership. Host auth homes
-- and company secrets are deliberately left untouched. No agent binding changes.
DO $$
DECLARE candidate record; application_id uuid; connection_id uuid; grant_id uuid;
BEGIN
FOR candidate IN
SELECT DISTINCT s.id AS secret_id, s.company_id, s.owner_user_id, s.user_secret_definition_id,
s.name, s.created_at, a.id AS agent_id,
CASE d.env_key WHEN 'ANTHROPIC_API_KEY' THEN 'anthropic' WHEN 'CLAUDE_CODE_OAUTH_TOKEN' THEN 'anthropic'
WHEN 'OPENAI_API_KEY' THEN 'openai' WHEN 'OPENROUTER_API_KEY' THEN 'openrouter' WHEN 'XAI_API_KEY' THEN 'xai' END AS provider,
CASE WHEN d.env_key = 'CLAUDE_CODE_OAUTH_TOKEN' THEN 'subscription' ELSE 'api_key' END AS method
FROM company_secrets s
JOIN user_secret_declarations d ON d.company_id = s.company_id AND d.user_secret_definition_id = s.user_secret_definition_id
JOIN agents a ON a.company_id = s.company_id AND a.id::text = d.target_id AND d.target_type = 'agent'
WHERE s.scope = 'user' AND s.owner_user_id IS NOT NULL AND s.status = 'active' AND s.deleted_at IS NULL
AND ((a.adapter_type = 'claude_local' AND d.env_key IN ('ANTHROPIC_API_KEY','CLAUDE_CODE_OAUTH_TOKEN'))
OR (a.adapter_type = 'codex_local' AND d.env_key = 'OPENAI_API_KEY')
OR (a.adapter_type = 'opencode_local' AND d.env_key = 'OPENROUTER_API_KEY')
OR (a.adapter_type = 'grok_local' AND d.env_key = 'XAI_API_KEY'))
ORDER BY s.created_at
LOOP
connection_id := overlay(overlay(md5('ai-connection:' || candidate.secret_id::text || ':' || candidate.provider || ':' || candidate.method) placing '5' from 13 for 1) placing '8' from 17 for 1)::uuid;
grant_id := overlay(overlay(md5('ai-grant:' || connection_id::text) placing '5' from 13 for 1) placing '8' from 17 for 1)::uuid;
INSERT INTO tool_applications(company_id,application_key,name,type,owner_user_id,metadata)
VALUES(candidate.company_id,'app-gallery:' || candidate.provider,
CASE candidate.provider WHEN 'anthropic' THEN 'Claude' WHEN 'openai' THEN 'OpenAI' WHEN 'openrouter' THEN 'OpenRouter' ELSE 'Grok' END,
'mcp_http',candidate.owner_user_id,jsonb_build_object('sourceTemplateKey',candidate.provider)) ON CONFLICT DO NOTHING;
SELECT id INTO application_id FROM tool_applications WHERE company_id = candidate.company_id
AND (application_key = 'app-gallery:' || candidate.provider OR name = CASE candidate.provider WHEN 'anthropic' THEN 'Claude' WHEN 'openai' THEN 'OpenAI' WHEN 'openrouter' THEN 'OpenRouter' ELSE 'Grok' END) LIMIT 1;
INSERT INTO tool_connections(id,company_id,application_id,name,uid,connection_purpose,transport,auth_kind,credential_policy,status,enabled,health_status,config,created_by_user_id)
VALUES(connection_id,candidate.company_id,application_id,candidate.name,'ai-' || connection_id::text,'ai','runtime_auth',
CASE WHEN candidate.method = 'subscription' THEN 'oauth' ELSE 'api_key' END,'per_user','active',true,'unknown',
jsonb_build_object('sourceTemplateKey',candidate.provider,'ai',jsonb_build_object('provider',candidate.provider,'method',candidate.method),'aiLegacyAdoption',true),candidate.owner_user_id)
ON CONFLICT DO NOTHING;
INSERT INTO connection_grants(id,company_id,connection_id,kind,subject_user_id,credential_secret_refs,created_by_user_id)
VALUES(grant_id,candidate.company_id,connection_id,'user',candidate.owner_user_id,
jsonb_build_array(jsonb_build_object('secretId',candidate.secret_id,'configPath','ai.credential','required',true,'versionSelector','latest')),candidate.owner_user_id)
ON CONFLICT DO NOTHING;
INSERT INTO user_secret_declarations(company_id,user_secret_definition_id,target_type,target_id,config_path,env_key)
VALUES(candidate.company_id,candidate.user_secret_definition_id,'tool_connection',connection_id::text,'ai.credential','ai.credential') ON CONFLICT DO NOTHING;
INSERT INTO ai_connection_defaults(company_id,user_id,provider,method,grant_id)
VALUES(candidate.company_id,candidate.owner_user_id,candidate.provider,candidate.method,grant_id) ON CONFLICT DO NOTHING;
INSERT INTO tool_connection_installs(company_id,connection_id,target_type,target_id,created_by_user_id)
VALUES(candidate.company_id,connection_id,'agent',candidate.agent_id::text,candidate.owner_user_id) ON CONFLICT DO NOTHING;
END LOOP;
END $$;