feat(connections): private connections + typed agent slots#3518
feat(connections): private connections + typed agent slots#3518tlgimenes wants to merge 101 commits into
Conversation
🧪 BenchmarkShould we run the Virtual MCP strategy benchmark for this PR? React with 👍 to run the benchmark.
Benchmark will run on the next push after you react. |
Release OptionsSuggested: Minor ( React with an emoji to override the release type:
Current version:
|
There was a problem hiding this comment.
8 issues found
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/mesh/src/web/components/add-storefront-modal.tsx">
<violation number="1" location="apps/mesh/src/web/components/add-storefront-modal.tsx:246">
P2: Duplicate connection resolver query logic in BrowseFlow and UrlEntry; extract a shared hook to reduce drift risk in this privacy-sensitive path.</violation>
<violation number="2" location="apps/mesh/src/web/components/add-storefront-modal.tsx:302">
P1: BrowseFlow can get stuck in a perpetual "installing" UI when CONNECTION_RESOLVE_FOR_USER fails because resolver error states are not handled.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
| @@ -20,19 +20,17 @@ import { | |||
| } from "@deco/ui/components/dialog.tsx"; | |||
There was a problem hiding this comment.
P1: BrowseFlow can get stuck in a perpetual "installing" UI when CONNECTION_RESOLVE_FOR_USER fails because resolver error states are not handled.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/add-storefront-modal.tsx, line 302:
<comment>BrowseFlow can get stuck in a perpetual "installing" UI when CONNECTION_RESOLVE_FOR_USER fails because resolver error states are not handled.</comment>
<file context>
@@ -279,7 +299,7 @@ function BrowseFlow({
}
- if (githubConnections.length === 0 && autoInstall.status === "idle") {
+ if (resolvedConnectionId === null) {
return (
<AutoInstallGitHubUI
</file context>
There was a problem hiding this comment.
Not a real issue (and cubic mislabeled the file — the diff_hunk actually targets apps/mesh/src/web/components/github-repo-picker.tsx, not add-storefront-modal.tsx which does not exist in this repo).
In PickerContent the resolver error state is handled explicitly before the "installing" fallback:
if (resolveQuery.isError) {
return (
<AutoInstallGitHubUI
status="error"
error={resolveQuery.error instanceof Error ? resolveQuery.error.message : "Failed to resolve GitHub connection."}
retry={() => { resolveQuery.refetch(); }}
/>
);
}
if (resolveQuery.isLoading || resolvedConnectionId === null) {
return <AutoInstallGitHubUI status="installing" ... />;
}InstallationPicker does the same (lines 529-543). On resolver failure the user sees the "Connection failed" UI with a Try again button — no perpetual "installing" state. Additionally, useAutoInstallGitHub is gated on resolveQuery.isSuccess && resolvedConnectionId === null, so it never fires on resolver error.
There was a problem hiding this comment.
Thanks for the feedback.
| // Resolve the caller's own mcp-github connection (user-private preferred, | ||
| // org-shared fallback). Avoids the cross-user leak in | ||
| // useConnections({ slug: 'mcp-github' }). | ||
| const resolveQuery = useQuery({ |
There was a problem hiding this comment.
P2: Duplicate connection resolver query logic in BrowseFlow and UrlEntry; extract a shared hook to reduce drift risk in this privacy-sensitive path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/add-storefront-modal.tsx, line 246:
<comment>Duplicate connection resolver query logic in BrowseFlow and UrlEntry; extract a shared hook to reduce drift risk in this privacy-sensitive path.</comment>
<file context>
@@ -239,17 +234,42 @@ function BrowseFlow({
+ // Resolve the caller's own mcp-github connection (user-private preferred,
+ // org-shared fallback). Avoids the cross-user leak in
+ // useConnections({ slug: 'mcp-github' }).
+ const resolveQuery = useQuery({
+ queryKey: KEYS.connectionResolveForUser(org.id, "mcp-github"),
+ queryFn: async () => {
</file context>
There was a problem hiding this comment.
No-op here: apps/mesh/src/web/components/add-storefront-modal.tsx doesn't exist in this PR or the codebase. The two CONNECTION_RESOLVE_FOR_USER useQuery blocks live in github-repo-picker.tsx (lines 189 and 483), which is being handled by the sibling comment 3314944426.
There was a problem hiding this comment.
Thanks for the feedback! I've saved this as a new learning to improve future reviews.
2cef9bf to
3c51820
Compare
Spec for per-user connection ownership with typed slot resolution in agents. Replaces the single-token-per-connection model that causes cross-contaminated GitHub installations in the import picker. New connections default to user-private; existing rows backfill to org-shared. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implementation plan for the schema-only first PR: connections.access column with M4 backfill, connection_aggregations.slot_app_id + XOR CHECK, partial unique indexes for R4 and slot uniqueness, plus Kysely type updates. Two tasks (migration + regression check), TDD-style. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds connections.access ('user' | 'org'; existing rows → 'org', new
rows → 'user') and connection_aggregations.slot_app_id (with relaxed
child_connection_id and XOR CHECK). Adds partial unique indexes for
R4 (one user-private connection per app_id per user) and slot
uniqueness per agent.
Schema-only — no runtime behavior change. Foundation for the
private-connections design (docs/superpowers/specs/2026-05-27-private-connections-design.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implementation plan for slot resolution at runtime. Four tasks: (1) surface slot rows on VirtualMCPEntity + clean up Phase 1 type drift, (2) implement the pure-function resolver + cache + typed error, (3) wire into createVirtualClient with OTel attributes, (4) regression sweep. Trigger/automation T1 wiring is already in place via meshContextFactory — no extra code needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds VirtualMCPSlot to the mesh-sdk type and a parallel `slots` array on VirtualMCPEntity / Create / Update inputs. VirtualMCPStorage partitions connection_aggregations rows into concrete `connections` (child_connection_id set) and `slots` (slot_app_id set), using the XOR CHECK from migration 097. Also fixes the Phase 1 follow-ups: RawAggregationRow.child_connection_id is now properly typed as string | null, and the deserialization read path no longer treats null as a concrete connection_id. The update path now preserves the un-touched field (loadExistingConnections / loadExistingSlots) so a caller that updates only connections does not wipe slots, and vice versa. Slot inserts ride the same direct-row delete/re-insert cycle as concrete connections. Test fixtures (passthrough-client.test, github-repo.test) and the well-known agent helper (defineWellKnownAgentVMCP) updated to include `slots: []` where required by the new entity shape. Storage layer only — runtime resolution lands in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous implementation tried to preserve slots when only connections were updated (and vice versa) via loadExistingSlots / loadExistingConnections helpers — but these helpers read from connection_aggregations AFTER the DELETE had already wiped the rows, so they always returned []. Update with only one field silently dropped the other. Fix: expand the pre-DELETE currentAggs snapshot to .selectAll() and partition it client-side into existingConnections / existingSlots. Use those snapshots as the fallback when the corresponding field is omitted from the input. Adds two tests covering the single-field-update path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure-function resolver that translates a typed slot (organizationId, invokerUserId, app_id) into a concrete connection_id: prefers caller's user-private connection, falls back to org-shared, returns null when neither matches. Includes SlotUnresolvedError for callers that want to throw, and SlotResolutionCache for per-run deduplication. No runtime caller yet — wiring lands in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createVirtualClient now resolves every slot on the agent before constructing the PassthroughClient. Slot resolution uses ctx.auth.user.id (or ctx.auth.apiKey.userId) as the invoker. Resolved connections are appended to the passthrough's children, so downstream tool dispatch is unchanged. Unresolved slots throw SlotUnresolvedError carrying the missing app_id so the UI can prompt the user to connect. OpenTelemetry span attributes (slot.<app>.connection_id, slot.<app>.access) are emitted per resolution for debuggability. Trigger/automation runs already pass automation.created_by as the identity into meshContextFactory (dbos-workflow.ts:171), so the T1 rule (slot resolves to trigger owner) requires no extra wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er fix Tight cut focused on landing the motivating bug fix. Adds access to the tool surface and storage list filtering (Phase 3a), then CONNECTION_RESOLVE_FOR_USER and the github-repo-picker refactor (Phase 4). Tabs/badges/promote-demote UI (Phase 3b) and the admin banner (Phase 5) are deferred to a follow-up plan — not required for the demo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… filtering
ConnectionEntitySchema now has access ('user' | 'org', default 'user').
ConnectionStorage.list and findById accept a viewerUserId; user-private
connections are hidden from other users. CONNECTION_CREATE and
CONNECTION_UPDATE accept access via the existing partial schemas.
Backend foundation for the GitHub-import refactor — the picker can now
trust that 'connections this user can see' excludes teammates' private
GitHub accounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin wrapper around the Phase 2 slot resolver that lets UI surfaces fetch the caller's user-private connection for a given app_id (with org-shared fallback). Powers the upcoming GitHub-import picker refactor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'Import from GitHub' picker now resolves the caller's own
mcp-github connection via the new app-only resolver tool, instead of
picking ambiguously from useConnections({slug:'mcp-github'}). Each
user sees only their own GitHub installations — the cross-contamination
bug where teammates' personal accounts appeared in the picker is now
fixed at the source.
New mcp-github connections are created with the DB default
access='user' (private to creator) so this fix takes effect without
any explicit caller-side change.
The same refactor is applied to add-storefront-modal (BrowseFlow and
UrlEntry), which had a parallel ambiguous-connection bug. The
multi-connection picker UI is removed: the resolver makes the choice
unambiguous, and slot semantics keep it that way going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The spec and phase plans were working documents that drove the implementation; the change itself is captured by the feature commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Make ConnectionViewer required (string | null | INTERNAL_VIEWER symbol) on findById/list to close the fail-open per-user visibility gap - Extract partitionAggregations helper in virtual.ts storage - Extract useResolveConnectionForUser hook for github-repo-picker - Share SelectionFilterFieldsShape between connection and slot schemas - Use getUserId(ctx) consistently in connection delete - Enforce non-empty app_id in CONNECTION_RESOLVE_FOR_USER Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These DB-backed tests were written against the pre-#3528 PGlite test API (`../database/test-db`, `createTestSchema`, `seedCommonTestFixtures`), which main removed when it moved storage tests to real Postgres. After rebasing, the dead imports broke knip, typecheck, and the unit test job. Convert all five to the real-Postgres harness, matching the established 087–092 / connection.integration.test.ts pattern: - Rename DB-backed files to *.integration.test.ts so they route to the storage-integration workflow and are excluded from the unit job. - Use connectTestPgDatabase / resetTestPgDatabase / seedCommonTestPgFixtures / closeTestPgDatabase. - Split slot-resolver: pure SlotResolutionCache/SlotUnresolvedError tests stay as a unit *.test.ts; the resolveSlot DB tests move to slot-resolver.integration.test.ts. - 097 migration test now asserts the already-migrated schema's behavior (default, CHECK, R4 unique index, aggregation XOR, slot uniqueness). Drop the backfill and `down` rollback cases: rolling the shared integration DB to 096 / dropping the access column would corrupt the schema for sibling integration files. Mirrors 087–092 (up-only). Verified: 31 tests pass against real Postgres; knip, fmt:check, and tsc all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GitHub import button was gated behind the client-side experimental_vibecode preference. Render it unconditionally in both the create-agent dropdown and the agent browser grid, and remove the now-dead flag definition along with its (only) Experimental settings toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… gate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/disable toggle Drop the heavyweight DependencySelectionDialog in favor of a simple EnableToggle switch on each connection/slot card. Enabled = expose everything (all-null), disabled = expose nothing (all empty arrays), with isSelectionEnabled tolerant of legacy subset selections. Extract connectionAttachTarget so the slot-vs-child decision (org → concrete child, user-private → typed slot keyed by app_id) is shared between the agent settings tab and the bulk add-to-agent flow. Slot cards now mirror the concrete connection layout with the same toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elper Route the six duplicated oauth-token save flows — connectApp, the add-connection dialog clone/custom-create paths, the connections page connect, connection settings re-auth, and GitHub auto-install — through a single best-effort persistDownstreamToken() helper. Removes ~190 lines of drift-prone duplicated fallback logic and normalizes GitHub auto-install to the same success-refresh and raw-token fallback behavior as the other call sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tles with email Extract reusable OAuth identity helpers (JWT decoding, email extraction from token info, title decoration) into a new oauth-identity module. This consolidates identity-related logic and enables showing the remote account identity (email) in connection titles, making multiple instances of the same app distinguishable in the UI (e.g., "Google Gmail (alice@acme.com)" vs "Google Gmail (bob@acme.com)"). The decoration is best-effort/cosmetic and rides along with the token persistence update, requiring no extra round-trip. Idempotent across re-auth: existing trailing parenthesized segments are replaced rather than appended. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…r handler The auth-error recovery path in the per-tool proxy route called connections.findById with only (connectionId, organizationId), but the ConnectionStoragePort signature now requires a third ConnectionViewer argument. Pass the authenticated viewer to match the other call sites in this file, fixing the TS2554 typecheck failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e34f650 to
c954a64
Compare
…ents Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verifies that createVirtualClientFrom collects all unresolved slot app_ids and reports every missing one in a single SlotUnresolvedError, satisfying the primary contract of the collect-all change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ecute
Verifies that when createVirtualClientFrom throws SlotUnresolvedError
(triggered by a null invokerUserId with a virtualMcp that has typed slots),
the execute generator emits a data-connect-required chunk via writer.write
and yields exactly one { text: '', error: '...connect...app-x...', finishReason: 'stop' }
value before terminating cleanly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ts are unresolved
- Remove the `unresolvedSlots` parameterized overload from query-keys.ts (its only caller `use-unresolved-slots.ts` was deleted in Task 9); only the `unresolvedSlotsPrefix()` variant remains and is still used. - Rewrite the `useSlotAppDisplays` JSDoc to drop references to the now- deleted `useUnresolvedSlots` hook and the old upfront gate; describes the inline ConnectCard context instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The parent connect-gate catch block emitted only a bare data-connect-required chunk then `return`ed, producing no `start` and no `finish` chunk. Three defects resulted: - The client (thread-connection.ts foldSubStream) only closes the SSE sub-stream and flips status streaming→ready on a `finish` chunk. With none, the UI hung in "streaming" forever. - dispatch-run.ts onFinish saw finishReason: undefined, so resolveThreadStatus fell through to "failed" — persisting a clean connect-card outcome as failed and firing the failure sound. - Without a `start` chunk the assistant message got id "", duplicating the card on SSE reconnect (mergeAndSort keys by id). Emit a complete UI-message-stream envelope: `start` (stable messageId) → a short terminal text part → data-connect-required → `finish`. Special-case resolveThreadStatus so a response carrying a data-connect-required part resolves to "requires_action" regardless of finishReason. Add unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dead gate code Switch ConnectCardInner from useChatStream() (throwing) to useOptionalChatStream() so persisted assistant messages render read-only in the monitoring route (no ActiveTaskProvider). The Retry button is only shown when a stream is active. Also remove the now-dead unresolvedSlotsPrefix invalidation and key from use-connect-app / query-keys, and update stale JSDoc in use-connect-app and connect-slot-row to reflect the JIT ConnectCard model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
9 issues found across 110 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/mesh/src/web/components/chat/connect-slot-row.tsx">
<violation number="1" location="apps/mesh/src/web/components/chat/connect-slot-row.tsx:50">
P3: `ready` is rendered as "Connecting…" with a spinner, which shows a false in-progress state after a successful connect. Render a completed label for `ready` instead.</violation>
</file>
<file name="apps/mesh/src/storage/derive-app-id.ts">
<violation number="1" location="apps/mesh/src/storage/derive-app-id.ts:80">
P1: `canonicalizeUrl` drops the URL scheme, so `http` and `https` endpoints can collapse to the same derived `app_id`. Include protocol in the canonical form to avoid false service collisions.</violation>
<violation number="2" location="apps/mesh/src/storage/derive-app-id.ts:88">
P2: `npx` package detection is fragile: it can pick flag values instead of the executed positional package.</violation>
</file>
<file name="apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.ts">
<violation number="1" location="apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.ts:17">
P3: Connection privacy checks are executed sequentially; run the lookups in parallel to avoid linear DB round-trip latency on create/update.</violation>
</file>
<file name="apps/mesh/migrations/097-connection-access-and-slots.ts">
<violation number="1" location="apps/mesh/migrations/097-connection-access-and-slots.ts:79">
P2: Rollback can fail after slot-based rows are created because `down()` sets `child_connection_id` back to NOT NULL before cleaning up rows where that column is intentionally NULL.</violation>
</file>
<file name="apps/mesh/src/storage/ports.ts">
<violation number="1" location="apps/mesh/src/storage/ports.ts:259">
P1: `findById` allows `organizationId` to be `undefined`, which enables unscoped cross-organization lookups when callers pass optional org context.</violation>
</file>
<file name="apps/mesh/src/api/app.ts">
<violation number="1" location="apps/mesh/src/api/app.ts:256">
P2: Avoid INTERNAL_VIEWER in this request-path lookup; it can pull another user’s private registry connection and use its `project_locator`.
(Based on your team's feedback about restricting INTERNAL_VIEWER to trusted infra and using user/null viewers in user-facing flows.) [FEEDBACK_USED]</violation>
</file>
<file name="apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts">
<violation number="1" location="apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts:21">
P2: This migration depends on mutable runtime code (`src/storage/derive-app-id`), which makes historical migration results non-deterministic across environments. Snapshot the derivation logic inside the migration (or a versioned migration-local helper) instead.</violation>
<violation number="2" location="apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts:56">
P2: Guard the UPDATE with `app_id IS NULL` to avoid clobbering concurrent writes between the read and write steps.</violation>
</file>
Note: This PR contains a large number of files. cubic only reviews up to 100 files per PR, so some files may not have been reviewed. cubic prioritizes the most important files to review.
On a pro plan you can use ultrareview for larger PRs.
Re-trigger cubic
| scheme === "https" ? "443" : scheme === "http" ? "80" : ""; | ||
| const port = u.port && u.port !== defaultPort ? `:${u.port}` : ""; | ||
| const path = u.pathname.replace(/\/+$/, ""); | ||
| return `${host}${port}${path}`; |
There was a problem hiding this comment.
P1: canonicalizeUrl drops the URL scheme, so http and https endpoints can collapse to the same derived app_id. Include protocol in the canonical form to avoid false service collisions.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/storage/derive-app-id.ts, line 80:
<comment>`canonicalizeUrl` drops the URL scheme, so `http` and `https` endpoints can collapse to the same derived `app_id`. Include protocol in the canonical form to avoid false service collisions.</comment>
<file context>
@@ -0,0 +1,99 @@
+ scheme === "https" ? "443" : scheme === "http" ? "80" : "";
+ const port = u.port && u.port !== defaultPort ? `:${u.port}` : "";
+ const path = u.pathname.replace(/\/+$/, "");
+ return `${host}${port}${path}`;
+}
+
</file context>
| findById(id: string): Promise<ConnectionEntity | null>; | ||
| findById( | ||
| id: string, | ||
| organizationId: string | undefined, |
There was a problem hiding this comment.
P1: findById allows organizationId to be undefined, which enables unscoped cross-organization lookups when callers pass optional org context.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/storage/ports.ts, line 259:
<comment>`findById` allows `organizationId` to be `undefined`, which enables unscoped cross-organization lookups when callers pass optional org context.</comment>
<file context>
@@ -228,18 +228,47 @@ export interface AsyncResearchJobStoragePort {
- findById(id: string): Promise<ConnectionEntity | null>;
+ findById(
+ id: string,
+ organizationId: string | undefined,
+ viewer: ConnectionViewer,
+ ): Promise<ConnectionEntity | null>;
</file context>
| `.execute(db); | ||
| await sql` | ||
| ALTER TABLE connection_aggregations | ||
| ALTER COLUMN child_connection_id SET NOT NULL |
There was a problem hiding this comment.
P2: Rollback can fail after slot-based rows are created because down() sets child_connection_id back to NOT NULL before cleaning up rows where that column is intentionally NULL.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/migrations/097-connection-access-and-slots.ts, line 79:
<comment>Rollback can fail after slot-based rows are created because `down()` sets `child_connection_id` back to NOT NULL before cleaning up rows where that column is intentionally NULL.</comment>
<file context>
@@ -0,0 +1,95 @@
+ `.execute(db);
+ await sql`
+ ALTER TABLE connection_aggregations
+ ALTER COLUMN child_connection_id SET NOT NULL
+ `.execute(db);
+ await sql`
</file context>
| value: `${DECO_STORE_URL}%`, | ||
| }, | ||
| limit: 1, | ||
| viewer: INTERNAL_VIEWER, |
There was a problem hiding this comment.
P2: Avoid INTERNAL_VIEWER in this request-path lookup; it can pull another user’s private registry connection and use its project_locator.
(Based on your team's feedback about restricting INTERNAL_VIEWER to trusted infra and using user/null viewers in user-facing flows.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/api/app.ts, line 256:
<comment>Avoid INTERNAL_VIEWER in this request-path lookup; it can pull another user’s private registry connection and use its `project_locator`.
(Based on your team's feedback about restricting INTERNAL_VIEWER to trusted infra and using user/null viewers in user-facing flows.) </comment>
<file context>
@@ -248,6 +253,7 @@ async function getDecoStoreProjectLocator(
value: `${DECO_STORE_URL}%`,
},
limit: 1,
+ viewer: INTERNAL_VIEWER,
},
);
</file context>
| viewer: INTERNAL_VIEWER, | |
| viewer: null, |
| app_id: row.app_id, | ||
| }); | ||
| if (appId) { | ||
| await sql`UPDATE connections SET app_id = ${appId} WHERE id = ${row.id}`.execute( |
There was a problem hiding this comment.
P2: Guard the UPDATE with app_id IS NULL to avoid clobbering concurrent writes between the read and write steps.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts, line 56:
<comment>Guard the UPDATE with `app_id IS NULL` to avoid clobbering concurrent writes between the read and write steps.</comment>
<file context>
@@ -0,0 +1,71 @@
+ app_id: row.app_id,
+ });
+ if (appId) {
+ await sql`UPDATE connections SET app_id = ${appId} WHERE id = ${row.id}`.execute(
+ db,
+ );
</file context>
| */ | ||
|
|
||
| import { sql, type Kysely } from "kysely"; | ||
| import { deriveAppId } from "../src/storage/derive-app-id"; |
There was a problem hiding this comment.
P2: This migration depends on mutable runtime code (src/storage/derive-app-id), which makes historical migration results non-deterministic across environments. Snapshot the derivation logic inside the migration (or a versioned migration-local helper) instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/migrations/098-org-scope-connections-and-derive-app-id.ts, line 21:
<comment>This migration depends on mutable runtime code (`src/storage/derive-app-id`), which makes historical migration results non-deterministic across environments. Snapshot the derivation logic inside the migration (or a versioned migration-local helper) instead.</comment>
<file context>
@@ -0,0 +1,71 @@
+ */
+
+import { sql, type Kysely } from "kysely";
+import { deriveAppId } from "../src/storage/derive-app-id";
+
+interface ConnRow {
</file context>
| const command = (stdio.command ?? "").trim(); | ||
| const args = (stdio.args ?? []).map((a) => a.trim()); | ||
| if (command === "npx" || command === "bunx") { | ||
| const pkg = args.find((a) => a.length > 0 && !a.startsWith("-")); |
There was a problem hiding this comment.
P2: npx package detection is fragile: it can pick flag values instead of the executed positional package.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/storage/derive-app-id.ts, line 88:
<comment>`npx` package detection is fragile: it can pick flag values instead of the executed positional package.</comment>
<file context>
@@ -0,0 +1,99 @@
+ const command = (stdio.command ?? "").trim();
+ const args = (stdio.args ?? []).map((a) => a.trim());
+ if (command === "npx" || command === "bunx") {
+ const pkg = args.find((a) => a.length > 0 && !a.startsWith("-"));
+ if (pkg) return `npx:${pkg}`;
+ }
</file context>
| disabled={busy} | ||
| onClick={() => connect(registryItem)} | ||
| > | ||
| {status === "connecting" || status === "ready" ? ( |
There was a problem hiding this comment.
P3: ready is rendered as "Connecting…" with a spinner, which shows a false in-progress state after a successful connect. Render a completed label for ready instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/chat/connect-slot-row.tsx, line 50:
<comment>`ready` is rendered as "Connecting…" with a spinner, which shows a false in-progress state after a successful connect. Render a completed label for `ready` instead.</comment>
<file context>
@@ -0,0 +1,80 @@
+ disabled={busy}
+ onClick={() => connect(registryItem)}
+ >
+ {status === "connecting" || status === "ready" ? (
+ <>
+ <Loading01 size={12} className="animate-spin" />
</file context>
| connectionStorage: ConnectionStoragePort, | ||
| organizationId: string, | ||
| ): Promise<void> { | ||
| for (const conn of connections) { |
There was a problem hiding this comment.
P3: Connection privacy checks are executed sequentially; run the lookups in parallel to avoid linear DB round-trip latency on create/update.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/tools/virtual/assert-concrete-children-org-scoped.ts, line 17:
<comment>Connection privacy checks are executed sequentially; run the lookups in parallel to avoid linear DB round-trip latency on create/update.</comment>
<file context>
@@ -0,0 +1,29 @@
+ connectionStorage: ConnectionStoragePort,
+ organizationId: string,
+): Promise<void> {
+ for (const conn of connections) {
+ const c = await connectionStorage.findById(
+ conn.connection_id,
</file context>
Adds an end-to-end test for the just-in-time connection gate (parent path). Creates a Virtual MCP agent with a synthetic unresolved typed slot, seeds a network-free smart tier so the decopilot run reaches tool assembly, posts a message, and asserts the run's SSE stream carries a data-connect-required part (with the missing app_id + agent title) and a finish chunk, and that the thread resolves to requires_action (not failed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nstanceof The parent connect-gate catch relies on `err instanceof SlotUnresolvedError`. The throw site imported via a relative path while the catch imported via the @/ alias; under Bun --hot these can resolve to duplicate module identities, making instanceof intermittently false (error escapes -> run logged as failed instead of showing the connect card). Standardize every importer on the @/ alias so all sites share one module instance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolved conflicts: - apps/mesh/migrations/index.ts: union of both sides' migrations. Kept 097-connection-access-and-slots + 098-org-scope-connections-and-derive-app-id (this branch) alongside main's 097-drop-local-docker-sandbox-state. Distinct names (no key collision), independent concerns; not renumbered since this branch's migrations have already applied to existing environments. - apps/mesh/src/api/routes/oauth-proxy.ts: additive import conflict — kept both this branch's INTERNAL_VIEWER import and main's oauth-proxy-metadata helpers + retry/RetryError (all used). Ran bun install to link main's new @decocms/std workspace package. Verified: bun run check (all workspaces), 55 unit tests, lint 0 errors, fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… was removed) The connect-card e2e POSTed sandboxProviderKind: "local-docker", which the local-docker-sandbox drop (merged from main) removed from the schema enum (now only "cluster" | "user-desktop"), so CI failed the message POST with 400 invalid_value before reaching the gate under test. Pin "cluster" instead: it's schema-valid and resolveDispatchTarget routes it to the in-cluster (loopback) path that needs no user-desktop link daemon — the same no-link path CI uses — so the decopilot run reaches assembleDecopilotTools and the parent connect-gate fires. Omitting the field is not safe: the default can resolve to "user-desktop" and 409. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
mcp_connectiondefaults toaccess='user'(private to its creator); only the creator + org-shared rows are visible to other tools/UI.connection_aggregations.slot_app_idlets an agent declare "I need anmcp-github" without binding to a specific connection. At runtime the slot resolves to the calling user's connection of that shape (or anorg-shared one as fallback). Triggers/automations resolve via the automation'screated_by.CONNECTION_RESOLVE_FOR_USERtool that returns only the caller's own mcp-github connection.Why
/user/installationson GitHub returns installations the authenticated user has any read access to — including teammates' personal accounts when collaborators are shared. Because Studio kept a single OAuth token per connection (migration 017 explicitly consolidated to this), opening the picker as one teammate showed installations belonging to another. This PR fixes that at the source: each user mints their own private connection and the resolver hands the picker the right one.What landed
f97d1201econnections.access+ backfill,connection_aggregations.slot_app_id+ XOR CHECK, R4 partial unique index. Schema-only.f3bb9e6f0slotsarray onVirtualMCPEntity(+ fix Phase 1RawAggregationRowdrift).59403a0bbVirtualMCPStorage.updatepreserving the untouched field across single-field updates.6e65b4bb0apps/mesh/src/core/slot-resolver.ts—resolveSlot,SlotResolutionCache,SlotUnresolvedError.d12fc9059createVirtualClientFrom, emit OTel span attributes.d79ddec9fConnectionEntity.accessfield +ConnectionStorage.list/findByIdtake aviewerUserIdand hide other users' private rows.bf9c6ef1eCONNECTION_RESOLVE_FOR_USERapp-only tool.63e5cadd1github-repo-pickerandadd-storefront-modaluse the resolver instead ofuseConnectionsambiguity.Test plan
bun run checkclean across all workspaces.bun run fmt:checkclean.access='user'in DB. Seeded a second teammate's private mcp-github connection in the same org and verified from the authenticated session that bothCONNECTION_RESOLVE_FOR_USERandCOLLECTION_CONNECTIONS_LISTreturn only the calling user's row.Known follow-ups (non-blocking)
access='org'.createVirtualClientFrom(currently embedsapp_idin the key — switch to span events or stable keys); extract apartitionAggregationshelper to dedupe the partition loop betweendeserializeVirtualMCPEntityandupdate; extract aresolveSlotsForAgenthelper to slim down the inlined ~70 LOC increateVirtualClientFrom.RawAggregationRow.child_connection_idis honest about nullability now; the local-type cast atvirtual.ts:50for the rawselectAll()results survives an extraas RawAggregationRow[]cast — safe today, worth tightening when slot-aware read paths multiply.🤖 Generated with Claude Code
Update — Just-in-time connection gate for agents & subagents (commits
29b5b78d8…39c624ee7)The earlier slot work gated the parent agent up front (block the composer until the user's slots resolve). But a parent can delegate to any agent via the
subtasktool, and those subagents' slots were never gated — so they blew up mid-conversation with a generic "Subtask failed". This follow-up replaces the up-front gate with one runtime mechanism shared by both flows.Design/Plan:
docs/superpowers/specs/2026-05-31-just-in-time-connection-gate-design.md,docs/superpowers/plans/2026-05-31-just-in-time-connection-gate.md.What changed
SlotUnresolvedErrornow collects all missingapp_ids + agent identity;createVirtualClientFromresolves every slot and throws once.data-connect-requiredstream chunk: the subagent boundary (subtask.ts, keyed bytoolCallId) and the parent boundary (decopilot harnessindex.ts). The model also gets plain-text so it doesn't blindly retry.ConnectCard(reusesConnectSlotRow+useSlotAppDisplays; Retry re-runs the last turn) rendered inline as a visible part; the up-frontConnectAgentGate+useUnresolvedSlotsare removed.returnemitted nofinishchunk → the client hung in "streaming" forever and the run logged as failed. It now emits a well-formedstart→ text →data-connect-required→finishenvelope, andresolveThreadStatusmaps a connect-required part torequires_action(no false failure / failure sound). Also fixed aConnectCardcrash in the read-only monitoring route (useOptionalChatStream).Test plan
SlotUnresolvedErrorcollect-all + agent identity;subtaskSlotUnresolvedErrorcatch path;resolveThreadStatusconnect-required →requires_action.bun testgreen (44 cases across the touched files).bun run checkclean (all workspaces);bun run fmt:checkclean; lint 0 errors; knip clean.apps/mesh/e2e/tests/connect-card.spec.ts): deterministic parent-gate path (slot unresolved →data-connect-requiredpart +finishchunk + cleanrequires_actionstatus) — passes. The subagent path depends on the real LLM choosingsubtask(non-deterministic) and is covered by thesubtask.tsunit test instead.🤖 Generated with Claude Code
Summary by cubic
Adds per-user private connections and typed agent slots with runtime resolution and an inline Connect card for agents and subagents. Fixes the GitHub import privacy leak and stuck installs by resolving the caller’s own GitHub connection by app_id; also merges latest
mainand links@decocms/std.New Features
resolveSlotwithSlotResolutionCache;SlotUnresolvedErrornow collects all missingapp_ids and agent identity.data-connect-requiredpart rendered as a chatConnectCardwith registry icon/name and inline OAuth (connectApp+useConnectApp+useSlotAppDisplays); thread status isrequires_action. Subagents (viasubtask) use the same flow.connections.access);ConnectionStorage.findById/listrequire a viewer and supportINTERNAL_VIEWER. Centralized token save (persistDownstreamToken) and OAuth identity decoration (oauth-identity) update titles like “Gmail (alice@acme.com)”.connectionAttachTargetauto‑picks slot vs child. Migrations 097–098 addconnections.access,connection_aggregations.slot_app_id, flip existing rows to org access, and backfill/stabilizeapp_ids viaderiveAppId. New toolCONNECTION_RESOLVE_FOR_USER; hooksuseResolveConnectionForUser; constantGITHUB_APP_ID.Bug Fixes
app_id(GITHUB_APP_ID) throughCONNECTION_RESOLVE_FOR_USERand attaches as a typed slot, fixing cross‑user leaks and the stuck picker.data-connect-required/finish envelope;resolveThreadStatusmaps torequires_action. Standardized@/imports keepinstanceofchecks stable under hot reload.connections.findById/list; trusted infra usesINTERNAL_VIEWER.Written for commit 649c4f8. Summary will update on new commits.