From 3dc3d7c6dc177ec3e6e82cbd51d2af0a2a3a5b22 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 17:16:15 -0800 Subject: [PATCH 1/4] chore: refresh app authoring skill digest --- .../src/implementation/executor-skill-digests.gen.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-templates/src/implementation/executor-skill-digests.gen.ts b/packages/app-templates/src/implementation/executor-skill-digests.gen.ts index a54fc1e55c..6299e7e17f 100644 --- a/packages/app-templates/src/implementation/executor-skill-digests.gen.ts +++ b/packages/app-templates/src/implementation/executor-skill-digests.gen.ts @@ -2,7 +2,7 @@ /** SHA-256 of each file the Executor app publishes as a skill, keyed by `/`. */ export const executorSkillDigests: Readonly> = { - "app-authoring/SKILL.md": "87118b0f1b98968a64fd6ba64dce39d1fde6c78f781f34ba85063ef4e44355a3", + "app-authoring/SKILL.md": "33e1e4c0262f7a2fc475c52ca67bf5ec7883d5067666eab1981944a6ed56d249", "app-authoring/accounts.md": "58cad0c32c94d719c50c63b53e798c0b6eae79a48f1942cc953a565ec73f9f91", "app-authoring/deploy.md": "679ae66cb0b832e0fb9081ca402eaf4469128677366bfbf38570e1036dd916c8", "app-authoring/events.md": "8f7465e35c6c94b06978b89680181df60c741225de3ba8158f22f76ee7e91a5a", From 6a897c4e5746c4a416e991775b0b12e8f1216658 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 17:17:48 -0800 Subject: [PATCH 2/4] fix: explain unlinked pinned connection accounts and offer relinking --- .../server/src/contracts/app-accounts.ts | 20 ++ apps/hosted/server/src/contracts/app-data.ts | 2 + apps/hosted/server/src/contracts/schedules.ts | 8 +- apps/hosted/server/src/contracts/skills.ts | 8 +- apps/hosted/server/src/contracts/tools.ts | 2 + apps/hosted/server/src/contracts/webhooks.ts | 3 +- apps/hosted/server/src/contracts/workflows.ts | 9 +- .../server/src/implementation/app-accounts.ts | 33 ++- apps/hosted/server/src/implementation/mcp.ts | 8 +- apps/hosted/web/src/contracts/errors.ts | 1 + e2e/test-plan.ts | 24 +- e2e/tests/connection-account-unlinked.spec.ts | 246 ++++++++++++++++++ e2e/tests/mcp-connections.spec.ts | 14 +- .../dashboard/connection-app-picker.tsx | 23 +- .../dashboard/scoped-connections.tsx | 10 +- 15 files changed, 391 insertions(+), 20 deletions(-) create mode 100644 e2e/tests/connection-account-unlinked.spec.ts diff --git a/apps/hosted/server/src/contracts/app-accounts.ts b/apps/hosted/server/src/contracts/app-accounts.ts index 9fed5ba76c..cebc03b8d8 100644 --- a/apps/hosted/server/src/contracts/app-accounts.ts +++ b/apps/hosted/server/src/contracts/app-accounts.ts @@ -31,6 +31,26 @@ import { RequireOrganization, } from "./organization.ts"; +/** A connection's own pinned accounts are no longer among the caller's usable app links. */ +export const ConnectionAccountUnlinked = UserFacingError.define({ + tag: "ConnectionAccountUnlinked", + status: 409, + fields: { app: AppId, accounts: Schema.NonEmptyArray(AccountId) }, + presentation: ({ accounts }) => ({ + title: "This connection needs an account", + description: + accounts.length === 1 + ? `This connection runs as account ${accounts[0]}, which you no longer have linked; relink it, or ask an operator to reset the connection.` + : `This connection runs as accounts ${accounts.join(", ")}, none of which you still have linked; relink one, or ask an operator to reset the connection.`, + recovery: { + action: "Relink the account, or ask an operator to reset the connection.", + instructions: + "Check this app's Accounts tab and the connection's runs-as selection. Relink an account the connection names, or ask an operator to reset the connection to an account you can use. Linking another account alone does not change this connection's selection. Do not change organization roles or bypass account access.", + }, + }), +}); +export type ConnectionAccountUnlinked = typeof ConnectionAccountUnlinked.Type; + const mismatch = { requirement: { title: "This app has no such account requirement here", diff --git a/apps/hosted/server/src/contracts/app-data.ts b/apps/hosted/server/src/contracts/app-data.ts index ac98cd7427..b8fb1d333a 100644 --- a/apps/hosted/server/src/contracts/app-data.ts +++ b/apps/hosted/server/src/contracts/app-data.ts @@ -1,3 +1,4 @@ +import { ConnectionAccountUnlinked } from "./app-accounts.ts"; import { withLegacyKeys } from "@executor-js/utils/legacy-keys"; import { RequiredAction } from "./authorization.ts"; /** Authored app operations, scoped to an explicit hosted organization and configured app. */ @@ -18,6 +19,7 @@ const payload = Schema.Struct({ input: Json, }).pipe(withLegacyKeys({ version: "deployment" })); const errors = [ + ConnectionAccountUnlinked, ...appDataErrors, OrganizationForbidden, AuthenticationUnavailable, diff --git a/apps/hosted/server/src/contracts/schedules.ts b/apps/hosted/server/src/contracts/schedules.ts index 6dbbf2a602..e9c0c8913b 100644 --- a/apps/hosted/server/src/contracts/schedules.ts +++ b/apps/hosted/server/src/contracts/schedules.ts @@ -1,3 +1,4 @@ +import { ConnectionAccountUnlinked } from "./app-accounts.ts"; /** Organization-aware schedule management, with browser-only human review. */ import { Context, Effect, Schema } from "effect"; import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api"; @@ -42,7 +43,12 @@ const organization = { organization: OrganizationReference }; const app = { ...organization, app: AppId }; const copy = { ...app, name: Schema.NonEmptyString, copy: ScheduleId }; const prefix = "/api/organizations/:organization"; -const errors = [...ScheduleErrors, OrganizationForbidden, Forbidden] as const; +const errors = [ + ...ScheduleErrors, + ConnectionAccountUnlinked, + OrganizationForbidden, + Forbidden, +] as const; /** How a by-name route picks between the app schedule and the caller's copy; agents read it. */ const scopeRule = "scope \"app\" is the app declaration's one schedule, which runs without a caller with the app's linked accounts; scope \"user\" is the caller's own copy of a user declaration. Without scope, the schedule saved under the name decides; with none saved, the app's declaration; otherwise user. When both the app schedule and the caller's copy are saved under the name, the call fails with ScheduleScopeRequired: retry with scope."; diff --git a/apps/hosted/server/src/contracts/skills.ts b/apps/hosted/server/src/contracts/skills.ts index 5ff763a3ae..49fb367c52 100644 --- a/apps/hosted/server/src/contracts/skills.ts +++ b/apps/hosted/server/src/contracts/skills.ts @@ -1,5 +1,6 @@ import { withLegacyKeys } from "@executor-js/utils/legacy-keys"; import { RequiredAction } from "./authorization.ts"; +import { ConnectionAccountUnlinked } from "./app-accounts.ts"; /** Organization-scoped skill reads expose only the skill directory, never general app source. */ import { Schema } from "effect"; import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api"; @@ -16,6 +17,7 @@ import { } from "@executor-js/sdk/core"; import { OrganizationReference, RequireOrganization } from "./organization.ts"; +const errors = [...AppSkillErrors, ConnectionAccountUnlinked] as const; const app = { organization: OrganizationReference, app: AppId }; const version = { version: SkillSelection.version, revision: SkillSelection.revision }; const prefix = "/api/organizations/:organization/apps/:app/skills"; @@ -26,7 +28,7 @@ export const HostedSkills = HttpApiGroup.make("skills") params: app, query: Schema.Struct(version).pipe(withLegacyKeys({ version: "deployment" })), success: AppSkillBundle, - error: AppSkillErrors, + error: errors, }).annotate(RequiredAction, "discover"), ) .add( @@ -34,7 +36,7 @@ export const HostedSkills = HttpApiGroup.make("skills") params: app, query: Schema.Struct(version).pipe(withLegacyKeys({ version: "deployment" })), success: AppSkillCatalog, - error: AppSkillErrors, + error: errors, }).annotate(RequiredAction, "discover"), ) .add( @@ -44,7 +46,7 @@ export const HostedSkills = HttpApiGroup.make("skills") withLegacyKeys({ version: "deployment" }), ), success: AppSkillDocument, - error: [...AppSkillErrors, AppSkillNotFound], + error: [...errors, AppSkillNotFound], }).annotate(RequiredAction, "discover"), ) .middleware(RequireOrganization); diff --git a/apps/hosted/server/src/contracts/tools.ts b/apps/hosted/server/src/contracts/tools.ts index 7fae524d20..88ac847382 100644 --- a/apps/hosted/server/src/contracts/tools.ts +++ b/apps/hosted/server/src/contracts/tools.ts @@ -1,3 +1,4 @@ +import { ConnectionAccountUnlinked } from "./app-accounts.ts"; import { withLegacyKeys } from "@executor-js/utils/legacy-keys"; import { RequiredAction } from "./authorization.ts"; /** Account-dependent discovery and execution within a configured app. */ @@ -54,6 +55,7 @@ import { const params = { organization: OrganizationReference, app: AppId }; const discoveryErrors = [ + ConnectionAccountUnlinked, StorageError, CredentialsError, AppNotFound, diff --git a/apps/hosted/server/src/contracts/webhooks.ts b/apps/hosted/server/src/contracts/webhooks.ts index 99a16455d0..dda909dd18 100644 --- a/apps/hosted/server/src/contracts/webhooks.ts +++ b/apps/hosted/server/src/contracts/webhooks.ts @@ -1,3 +1,4 @@ +import { ConnectionAccountUnlinked } from "./app-accounts.ts"; import { RequiredAction } from "./authorization.ts"; /** Organization-authorized management of ordinary app webhook subscriptions. */ import { Schema } from "effect"; @@ -24,7 +25,7 @@ const app = { organization: OrganizationReference, app: AppId }; const target = { ...app, subscription: WebhookId }; const source = { ...app, account: AccountId }; const path = "/api/organizations/:organization/apps/:app"; -const errors = [...WebhookErrors, OrganizationForbidden] as const; +const errors = [...WebhookErrors, ConnectionAccountUnlinked, OrganizationForbidden] as const; /** * Subscriptions and webhook sources run as their actor. Reads and changes of one's own need app * use; changing someone else's needs app management. Creating one runs it as the caller. Every diff --git a/apps/hosted/server/src/contracts/workflows.ts b/apps/hosted/server/src/contracts/workflows.ts index e945a0e684..869dbe5578 100644 --- a/apps/hosted/server/src/contracts/workflows.ts +++ b/apps/hosted/server/src/contracts/workflows.ts @@ -1,3 +1,4 @@ +import { ConnectionAccountUnlinked } from "./app-accounts.ts"; /** Organization authorization surrounds app-scoped workflow lifecycle operations. */ import { withLegacyKeys } from "@executor-js/utils/legacy-keys"; import { Schema } from "effect"; @@ -21,8 +22,12 @@ import { } from "./organization.ts"; const app = { organization: OrganizationReference, app: AppId }; const run = { ...app, run: WorkflowRunId }; -const errors = [...WorkflowErrors, OrganizationForbidden] as const; -const definitionErrors = [...WorkflowDefinitionErrors, OrganizationForbidden] as const; +const errors = [...WorkflowErrors, ConnectionAccountUnlinked, OrganizationForbidden] as const; +const definitionErrors = [ + ...WorkflowDefinitionErrors, + ConnectionAccountUnlinked, + OrganizationForbidden, +] as const; const path = "/api/organizations/:organization/apps/:app"; /** * Members who can use the app read definitions and start runs with their accounts. Runs belong to diff --git a/apps/hosted/server/src/implementation/app-accounts.ts b/apps/hosted/server/src/implementation/app-accounts.ts index f934bcc70f..a9798f1edc 100644 --- a/apps/hosted/server/src/implementation/app-accounts.ts +++ b/apps/hosted/server/src/implementation/app-accounts.ts @@ -7,6 +7,8 @@ */ import { AccountId, + CurrentCaller, + type LinkedAccounts, AccountNotFound, AppId, StorageError, @@ -22,12 +24,12 @@ import { type Executor, type WebhookSourceConfig, } from "@executor-js/sdk/core"; -import { permitsAccounts, permittedTargets } from "@executor-js/authorization"; +import { permitsAccounts, permitsApp, permittedTargets } from "@executor-js/authorization"; import { Effect, Schema } from "effect"; import type { SqlClient } from "effect/sql"; import { CurrentUserId } from "../contracts/auth.ts"; import { CurrentAuthorization } from "../contracts/authorization.ts"; -import { RequirementMismatch } from "../contracts/app-accounts.ts"; +import { ConnectionAccountUnlinked, RequirementMismatch } from "../contracts/app-accounts.ts"; import { OrganizationForbidden } from "../contracts/organization.ts"; import { AppAccountReads, @@ -164,6 +166,29 @@ const callerRequirements = (version: AppVersion, links?: CallerLinks) => }; }); +/** + * A connection pinned only to accounts cannot run with zero matching user links, even for + * many(). Check before resolving plain requirements too, and before MCP's accountless shortcut. + * Only name IDs in the caller's own connection policy, never look up another person's account. + */ +export const requireConnectionAccounts = (app: AppId, linked: LinkedAccounts) => + Effect.gen(function* () { + const policy = yield* CurrentAuthorization; + const caller = yield* CurrentCaller; + if (caller?.provenance?.connectionId === undefined || !permitsApp(policy, app)) return; + const targets = permittedTargets(policy.tools, app); + if (targets === undefined || targets.app || Object.keys(linked).length === 0) return; + const accounts = [...targets.accounts]; + const first = accounts[0]; + if ( + first !== undefined && + !Object.values(linked).some((requirement) => + requirement.accounts.some((account) => targets.accounts.has(account.id)), + ) + ) + return yield* new ConnectionAccountUnlinked({ app, accounts: [first, ...accounts.slice(1)] }); + }); + /** * The accounts an operation the caller starts runs with, for every requirement of the version, * resolved by the shared rules: app requirements from the app links, user requirements from the @@ -172,10 +197,12 @@ const callerRequirements = (version: AppVersion, links?: CallerLinks) => */ export const versionAccounts = (version: AppVersion, links?: CallerLinks) => Effect.gen(function* () { + const requirements = yield* callerRequirements(version, links); + yield* requireConnectionAccounts(version.app, requirements.userLinks); const resolved = yield* resolveLinkedAccounts({ app: version.app, version: version.version, - ...(yield* callerRequirements(version, links)), + ...requirements, }); const policy = yield* CurrentAuthorization; if (!permitsAccounts(policy, version.app, userAccountIds(resolved))) diff --git a/apps/hosted/server/src/implementation/mcp.ts b/apps/hosted/server/src/implementation/mcp.ts index fde4cd0ca9..356a3ef131 100644 --- a/apps/hosted/server/src/implementation/mcp.ts +++ b/apps/hosted/server/src/implementation/mcp.ts @@ -6,7 +6,7 @@ import { } from "../contracts/product-analytics.ts"; import { McpSchema } from "effect/ai"; import { authorizeApp, authorizeSavedTool, authorizeTool } from "./authorization.ts"; -import { callerLinkedAccounts, linkedAccounts } from "./app-accounts.ts"; +import { callerLinkedAccounts, linkedAccounts, requireConnectionAccounts } from "./app-accounts.ts"; import { GrantForbidden, untargetedLinkRefusal } from "@executor-js/mcp-auth"; import { permittedAppIds, @@ -189,13 +189,15 @@ export const hostedMcpBackend = Effect.gen(function* () { ) ? yield* discoveryAccounts : []; - return linkedRequirements( + const accounts = linkedRequirements( requirements.accounts, "user", linked .filter((account) => account.app === app.id) .map((link) => ({ requirement: link.requirement, account: link })), ); + yield* requireConnectionAccounts(app.id, accounts); + return accounts; }); const backend = { eventDefinitions: (input) => @@ -245,6 +247,8 @@ export const hostedMcpBackend = Effect.gen(function* () { OrganizationForbidden: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })), AppNotFound: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })), AccountRequired: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })), + ConnectionAccountUnlinked: () => + Effect.fail(new ElicitationFailed({ reason: "forbidden" })), AppNotDeployed: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })), VersionNotFound: () => Effect.fail(new ElicitationFailed({ reason: "forbidden" })), StorageError: () => Effect.fail(new ElicitationFailed({ reason: "transport" })), diff --git a/apps/hosted/web/src/contracts/errors.ts b/apps/hosted/web/src/contracts/errors.ts index c13f47f07e..ad1233254b 100644 --- a/apps/hosted/web/src/contracts/errors.ts +++ b/apps/hosted/web/src/contracts/errors.ts @@ -27,6 +27,7 @@ const errorMessage = Match.type().pipe( })[reason], ScheduleNotFound: () => "This schedule or run is no longer available.", ConnectionNotFound: () => "This connection was revoked or no longer exists.", + ConnectionAccountUnlinked: (error) => error.description, ConnectionIdTaken: () => "This connection could not be created. Close the form and try again.", ConnectedAgentNotFound: () => "This agent is no longer connected.", ConnectionKeyNotFound: () => "This key was already revoked.", diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 5ede864484..56eb9ceeec 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -6947,6 +6947,28 @@ export const scenarios = plan({ local: na("Local connections are covered by the local scoped connection scenario."), }, }, + connectionAccountUnlinkedMany: { + fixtures: "actors", + file: "connection-account-unlinked.spec.ts", + title: + "Pinned many-account connections report unlinked accounts and recover without changing organization access", + targets: { + "self-host": scheduled, + cloud: scheduled, + local: na("Hosted member accounts and authorization."), + }, + }, + connectionAccountUnlinkedPlain: { + fixtures: "actors", + file: "connection-account-unlinked.spec.ts", + title: + "Pinned plain-account connections report unlinked accounts and recover without changing organization access", + targets: { + "self-host": scheduled, + cloud: scheduled, + local: na("Hosted member accounts and authorization."), + }, + }, scopedConnectionAccounts: { fixtures: "actors", file: "mcp-connections.spec.ts", @@ -6961,7 +6983,7 @@ export const scenarios = plan({ fixtures: "actors", file: "mcp-connections.spec.ts", title: - "a scoped connection refuses a call that runs with an account linked in place of the granted one during an execution, as the grant not allowing that account", + "a scoped connection refuses a call after its pinned account is replaced during an execution, as an unlinked connection account", targets: { "self-host": scheduled, cloud: scheduled, diff --git a/e2e/tests/connection-account-unlinked.spec.ts b/e2e/tests/connection-account-unlinked.spec.ts new file mode 100644 index 0000000000..dab724e1b8 --- /dev/null +++ b/e2e/tests/connection-account-unlinked.spec.ts @@ -0,0 +1,246 @@ +/** A lost runs-as link is account setup, not missing organization permission. */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Redacted, Schema } from "effect"; +import { randomUUID } from "node:crypto"; +import { scenarios } from "../test-plan.ts"; +import { Actors } from "../support/actors.ts"; +import { Api, body } from "../support/api.ts"; +import { Browser } from "../support/browser.ts"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import { App, Resource } from "../support/contracts.ts"; +import { Evidence } from "../support/evidence.ts"; +import { McpClient } from "../support/mcp-client.ts"; +import { appsManifest } from "../support/apps-release.ts"; +import { connectionsPath, linkedAccountIds, unlinkAccount } from "../support/app-accounts.ts"; +import { ConnectionView } from "../support/mcp-connections.ts"; + +const Token = Schema.Struct({ id: Schema.String, key: Schema.RedactedFromValue(Schema.String) }); + +layer(HostedLive, { excludeTestServices: true })("Unlinked connection accounts", (it) => { + for (const [cardinality, scenario] of [ + ["many", scenarios.connectionAccountUnlinkedMany], + ["one", scenarios.connectionAccountUnlinkedPlain], + ] as const) { + it.effect(scenario.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + mcp = yield* McpClient, + browser = yield* Browser, + evidence = yield* Evidence; + const prefix = `/api/organizations/${actors.organization.id}`; + const deployed = yield* api.request(actors.owner, "POST", `${prefix}/apps/deploy`, { + name: `Pinned ${cardinality} ${randomUUID().slice(0, 8)}`, + files: [ + appsManifest, + { + path: "index.ts", + content: `import { defineApp, defineProvider, secrets, object, string, query, router } from "apps"; +const service = defineProvider({ name: "Pinned account fixture", auth: { key: secrets({ label: "Key", fields: object({ token: string() }) }) } }); +export default defineApp({ user: { accounts: { service: service${cardinality === "many" ? ".many()" : ""} } } }, async ctx => ({ tools: router({ who: query({ input: object({}), description: "Report linked account IDs" }, async () => ${cardinality === "many" ? "ctx.user.accounts.service.map(account => account.id)" : "[ctx.user.accounts.service.id]"}) }) }));`, + }, + ], + }); + expect(deployed.status, JSON.stringify(deployed.body)).toBe(200); + const app = yield* body(App, deployed); + const path = `${prefix}/apps/${app.id}`; + const accounts: string[] = []; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* api.request(actors.owner, "DELETE", path); + for (const account of accounts) + yield* api.request(actors.member, "DELETE", `${prefix}/accounts/${account}`); + }).pipe(Effect.orDie), + ); + const share = (kind: "everyone" | "private") => + Effect.gen(function* () { + const access = yield* body( + Schema.Struct({ revision: Schema.String }), + yield* api.request(actors.owner, "GET", `${path}/access`), + ); + const result = yield* api.request(actors.owner, "PATCH", `${path}/access`, { + revision: access.revision, + audience: { kind }, + }); + expect(result.status, JSON.stringify(result.body)).toBe(200); + }); + yield* share("everyone"); + const connect = (label: string) => + Effect.gen(function* () { + const started = yield* api.request( + actors.member, + "POST", + connectionsPath(path, "user", "service"), + { + destination: { kind: "personal" }, + }, + ); + expect(started.status, JSON.stringify(started.body)).toBe(200); + const connection = yield* body(Resource, started); + const saved = yield* api.request( + actors.member, + "POST", + `${prefix}/connections/${connection.id}/submit`, + { + method: "key", + label, + fields: { token: "synthetic-only" }, + }, + ); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + const account = (yield* body(Resource, saved)).id; + accounts.push(account); + return account; + }); + const pinned = yield* connect("Private pinned label"); + const created = yield* api.request(actors.member, "POST", `${prefix}/mcp-connections`, { + id: randomUUID(), + name: "Pinned member connection", + apps: [ + { app: app.id, runsAs: [{ kind: "account", id: pinned }], tools: { kind: "all" } }, + ], + }); + expect(created.status, JSON.stringify(created.body)).toBe(200); + const connection = yield* body(ConnectionView, created); + yield* Effect.addFinalizer(() => + api + .request(actors.member, "POST", `${prefix}/mcp-connections/${connection.id}/revoke`) + .pipe(Effect.orDie), + ); + const key = yield* body( + Token, + yield* api.request(actors.member, "POST", "/api/auth/api-key/create", { + name: "Pinned fixture", + }), + ); + yield* Effect.addFinalizer(() => + api + .request(actors.member, "POST", "/api/auth/api-key/delete", { keyId: key.id }) + .pipe(Effect.orDie), + ); + const client = yield* mcp.connect(key.key, "pinned-member", { + organization: actors.organization.id, + connection: connection.id, + }); + const run = (step: string, code: string) => + client.use(step, (client, signal) => + client.callTool({ name: "execute", arguments: { code } }, undefined, { signal }), + ); + const call = `return await tools[${JSON.stringify(app.slug)}].who({});`; + expect((yield* run("Pinned account runs", call)).structuredContent).toMatchObject({ + execution: { ok: true, value: [pinned] }, + }); + const session = yield* api.session(); + const scoped = { + authorization: `Bearer ${Redacted.value(key.key)}`, + "x-executor-caller-connection": connection.id, + }; + const message = `This connection runs as account ${pinned}, which you no longer have linked; relink it, or ask an operator to reset the connection.`; + const checkMissing = (stage: string) => + Effect.gen(function* () { + for (const suffix of ["", "/index", "/who"]) { + const response = yield* api.request( + session, + "GET", + `${path}/tools${suffix}`, + undefined, + scoped, + ); + yield* evidence.json( + `${stage}-discovery${suffix.replaceAll("/", "-")}.json`, + response, + ); + expect(response.status, JSON.stringify(response.body)).toBe(409); + expect(response.body).toMatchObject({ + _tag: "ConnectionAccountUnlinked", + app: app.id, + accounts: [pinned], + message, + }); + } + const called = yield* api.request( + session, + "POST", + `${path}/tools/call`, + { tool: "who", input: {} }, + scoped, + ); + expect(called.status, JSON.stringify(called.body)).toBe(409); + expect(called.body).toMatchObject({ _tag: "ConnectionAccountUnlinked", message }); + for (const [operation, code] of [ + ["call", call], + [ + "search", + `return await tools.search({ query: "Report linked account IDs", limit: 5 });`, + ], + [ + "describe", + `return await tools.search.describe({ paths: [${JSON.stringify(`${app.slug}.who`)}] });`, + ], + ] as const) { + const result = yield* run(`${stage}: ${operation}`, code); + yield* evidence.json(`${stage}-${operation}.json`, result.structuredContent); + const serialized = JSON.stringify(result.structuredContent); + expect(serialized).toContain("ConnectionAccountUnlinked"); + expect(serialized).toContain(message); + expect(serialized).not.toContain("OrganizationForbidden"); + expect(serialized).not.toContain("Private pinned label"); + expect(serialized).not.toContain("Other private label"); + } + }); + yield* unlinkAccount(actors.member, path, "service", pinned); + expect(yield* linkedAccountIds(actors.member, path)).toEqual([]); + yield* checkMissing("zero-links"); + if (cardinality === "many") { + const empty = yield* api.request(actors.member, "POST", `${path}/tools/call`, { + tool: "who", + input: {}, + }); + expect(empty.status, JSON.stringify(empty.body)).toBe(200); + expect(empty.body).toEqual([]); + } + + // The existing connection status shows the missing link and offers a working repair. + yield* browser.login(actors.member); + yield* browser.use("Open the connection needing an account", (page) => + page.goto(`/org/${actors.organization.slug}/connect/${connection.id}`), + ); + yield* browser.use("Needs account with relink recovery", (page) => + page.getByText("Needs account — save to relink", { exact: true }).waitFor(), + ); + yield* browser.checkpoint("Connection needs its pinned account"); + yield* browser.use("Relink by saving the connection", (page) => + page.getByRole("button", { name: "Save changes", exact: true }).click(), + ); + yield* browser.use("Account setup status clears", (page) => + page + .getByText("Needs account — save to relink", { exact: true }) + .waitFor({ state: "hidden" }), + ); + expect(yield* linkedAccountIds(actors.member, path)).toEqual([pinned]); + expect( + (yield* run("Same connection recovers after relinking", call)).structuredContent, + ).toMatchObject({ execution: { ok: true, value: [pinned] } }); + + // Other links neither repair nor leak into this connection. An unrestricted call works. + yield* unlinkAccount(actors.member, path, "service", pinned); + const other = yield* connect("Other private label"); + yield* checkMissing("other-linked"); + const general = yield* api.request(actors.member, "POST", `${path}/tools/call`, { + tool: "who", + input: {}, + }); + expect(general.status, JSON.stringify(general.body)).toBe(200); + expect(general.body).toEqual([other]); + // Real app authorization still wins, even with the pinned link missing. + yield* share("private"); + const forbidden = yield* api.request(session, "GET", `${path}/tools`, undefined, scoped); + expect(forbidden.status, JSON.stringify(forbidden.body)).toBe(403); + expect(forbidden.body).toMatchObject({ _tag: "OrganizationForbidden" }); + }).pipe(Effect.provide(McpClient.layer)), + ), + ); + } +}); diff --git a/e2e/tests/mcp-connections.spec.ts b/e2e/tests/mcp-connections.spec.ts index eda284a776..af44f5de3f 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -25,7 +25,6 @@ import { readWriteAppFiles, revokeClientGrants, TargetRefusal, - targetRefusal, } from "../support/mcp-connections.ts"; import { holdTools, untilHeld } from "../support/held-call.ts"; import { Target } from "../support/platform.ts"; @@ -552,13 +551,20 @@ return { first, second };`, }), )(result.structuredContent); expect(execution.value.first).toBe("granted"); - // The connection does not allow the replacement, so the call is refused for that and - // never runs with it. + // The pinned account is no longer linked. Report setup recovery, not an organization + // permission failure, and never run with or name the replacement. expect( yield* Schema.decodeUnknownEffect(Schema.fromJsonString(TargetRefusal))( execution.value.second.refused, ), - ).toEqual(targetRefusal(app.id, replacement)); + ).toMatchObject({ + code: "ConnectionAccountUnlinked", + status: 409, + message: `This connection runs as account ${granted}, which you no longer have linked; relink it, or ask an operator to reset the connection.`, + recovery: { action: "Relink the account, or ask an operator to reset the connection." }, + retryable: false, + }); + expect(execution.value.second.refused).not.toContain(replacement); }).pipe(Effect.provide(McpClient.layer)), ), ); diff --git a/packages/ui/src/implementation/dashboard/connection-app-picker.tsx b/packages/ui/src/implementation/dashboard/connection-app-picker.tsx index 0001a47ff9..331b73e7ba 100644 --- a/packages/ui/src/implementation/dashboard/connection-app-picker.tsx +++ b/packages/ui/src/implementation/dashboard/connection-app-picker.tsx @@ -33,6 +33,7 @@ export interface Choice { /** Underlying accounts, used to apply one account across many apps at once. */ readonly accounts: readonly AccountId[]; readonly provider: ProviderId; + readonly linked: boolean; } /** Everything an app offers a connection, computed once per inventory rather than per render. */ @@ -65,6 +66,7 @@ export function appChoices( .map((account): Choice => ({ target: { kind: "account", id: account.id, label: account.label || "Unnamed account" }, description: linked.has(account.id) ? "Linked to this app" : "Links when saved", + linked: linked.has(account.id), accounts: [account.id], provider: account.provider, })); @@ -110,6 +112,17 @@ export function selectionIssue(choices: AppChoices, selection: ConnectionApp): s return undefined; } +/** Saving can repair a selection whose accounts are still usable but no longer linked. */ +export const selectionUnlinked = (choices: AppChoices, selection: ConnectionApp) => + choices.needsAccount && + selection.targets.length > 0 && + !selection.targets.some((target) => + choices.options.some( + (option) => + option.linked && connectionTargetKey(option.target) === connectionTargetKey(target), + ), + ); + /** One dense row per app. Controls appear only once the app is included. */ export function ConnectionAppRow({ choices, @@ -132,6 +145,10 @@ export function ConnectionAppRow({ const id = useId(); const { app } = choices; const issue = selection === undefined ? undefined : selectionIssue(choices, selection); + // Saved accounts can still be available to choose after their app link was removed. + // Saving relinks them, so this is a status/recovery hint, not a validation blocker. + const unlinked = selection !== undefined && selectionUnlinked(choices, selection); + const status = issue ?? (unlinked ? "Needs account — save to relink" : undefined); const only = choices.options.length === 1 ? choices.options[0] : undefined; const setTools = (tools: ConnectionTools) => { if (selection !== undefined) onChange({ ...selection, tools }); @@ -161,14 +178,16 @@ export function ConnectionAppRow({ {renderConnectAccount !== undefined && choices.needsAccount && choices.options.length === 0 ? "No connected accounts" - : (issue ?? + : (status ?? (!choices.needsAccount ? "No account needed" : choices.options.length === 0 diff --git a/packages/ui/src/implementation/dashboard/scoped-connections.tsx b/packages/ui/src/implementation/dashboard/scoped-connections.tsx index 660eb5b30c..be62c298cf 100644 --- a/packages/ui/src/implementation/dashboard/scoped-connections.tsx +++ b/packages/ui/src/implementation/dashboard/scoped-connections.tsx @@ -80,6 +80,7 @@ import { accountOption, initialSelection, selectionIssue, + selectionUnlinked, type AppChoices, } from "./connection-app-picker.tsx"; import { useIncrementalList } from "./incremental-list.tsx"; @@ -535,7 +536,12 @@ function ConnectionForm({ ); const input = connectionInput(draft); const creating = stored === undefined; - const dirty = creating || JSON.stringify(input) !== JSON.stringify(connectionInput(initial)); + const relinking = draft.apps.some((selection) => { + const choice = choices.get(selection.id); + return choice !== undefined && selectionUnlinked(choice, selection); + }); + const dirty = + creating || relinking || JSON.stringify(input) !== JSON.stringify(connectionInput(initial)); const valid = input !== undefined && draft.apps.length > 0 && blocked === 0; const setSelection = (id: AppId, next: ConnectionApp | undefined) => setDraft((previous) => ({ @@ -773,6 +779,8 @@ function ConnectionForm({ ) : creating ? ( `${plural(draft.apps.length, "app")} turned on` + ) : relinking ? ( + "Save to relink this connection's accounts" ) : ( "Unsaved changes" )} From d7c3f106f73d74c02b080a0e8571610829ede6cb Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 17:30:32 -0800 Subject: [PATCH 3/4] fix: describe owner-only recovery for unlinked connection accounts --- apps/hosted/server/src/contracts/app-accounts.ts | 8 ++++---- e2e/tests/connection-account-unlinked.spec.ts | 7 ++++++- e2e/tests/mcp-connections.spec.ts | 6 ++++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/hosted/server/src/contracts/app-accounts.ts b/apps/hosted/server/src/contracts/app-accounts.ts index cebc03b8d8..7defbc9019 100644 --- a/apps/hosted/server/src/contracts/app-accounts.ts +++ b/apps/hosted/server/src/contracts/app-accounts.ts @@ -40,12 +40,12 @@ export const ConnectionAccountUnlinked = UserFacingError.define({ title: "This connection needs an account", description: accounts.length === 1 - ? `This connection runs as account ${accounts[0]}, which you no longer have linked; relink it, or ask an operator to reset the connection.` - : `This connection runs as accounts ${accounts.join(", ")}, none of which you still have linked; relink one, or ask an operator to reset the connection.`, + ? `This connection runs as account ${accounts[0]}, which is no longer linked to this app for you. Relink it, or edit the connection to run as another of your accounts.` + : `This connection runs as accounts ${accounts.join(", ")}, none of which are still linked to this app for you. Relink one, or edit the connection to run as another of your accounts.`, recovery: { - action: "Relink the account, or ask an operator to reset the connection.", + action: "Relink the account, or edit the connection to run as another account.", instructions: - "Check this app's Accounts tab and the connection's runs-as selection. Relink an account the connection names, or ask an operator to reset the connection to an account you can use. Linking another account alone does not change this connection's selection. Do not change organization roles or bypass account access.", + "Only the connection's owner can fix this; organization admins cannot edit another member's connection. Open the connection under Connect and save it to relink the account it runs as, or choose another of your accounts for this app. Relinking the account on the app's Accounts tab also works. For a single-account app, relinking replaces your current link. Linking a different account does not change which account the connection runs as. Do not change organization roles or bypass account access.", }, }), }); diff --git a/e2e/tests/connection-account-unlinked.spec.ts b/e2e/tests/connection-account-unlinked.spec.ts index dab724e1b8..7938023678 100644 --- a/e2e/tests/connection-account-unlinked.spec.ts +++ b/e2e/tests/connection-account-unlinked.spec.ts @@ -137,7 +137,7 @@ export default defineApp({ user: { accounts: { service: service${cardinality === authorization: `Bearer ${Redacted.value(key.key)}`, "x-executor-caller-connection": connection.id, }; - const message = `This connection runs as account ${pinned}, which you no longer have linked; relink it, or ask an operator to reset the connection.`; + const message = `This connection runs as account ${pinned}, which is no longer linked to this app for you. Relink it, or edit the connection to run as another of your accounts.`; const checkMissing = (stage: string) => Effect.gen(function* () { for (const suffix of ["", "/index", "/who"]) { @@ -158,6 +158,11 @@ export default defineApp({ user: { accounts: { service: service${cardinality === app: app.id, accounts: [pinned], message, + recovery: { + action: "Relink the account, or edit the connection to run as another account.", + instructions: + "Only the connection's owner can fix this; organization admins cannot edit another member's connection. Open the connection under Connect and save it to relink the account it runs as, or choose another of your accounts for this app. Relinking the account on the app's Accounts tab also works. For a single-account app, relinking replaces your current link. Linking a different account does not change which account the connection runs as. Do not change organization roles or bypass account access.", + }, }); } const called = yield* api.request( diff --git a/e2e/tests/mcp-connections.spec.ts b/e2e/tests/mcp-connections.spec.ts index af44f5de3f..4151e6d3fd 100644 --- a/e2e/tests/mcp-connections.spec.ts +++ b/e2e/tests/mcp-connections.spec.ts @@ -560,8 +560,10 @@ return { first, second };`, ).toMatchObject({ code: "ConnectionAccountUnlinked", status: 409, - message: `This connection runs as account ${granted}, which you no longer have linked; relink it, or ask an operator to reset the connection.`, - recovery: { action: "Relink the account, or ask an operator to reset the connection." }, + message: `This connection runs as account ${granted}, which is no longer linked to this app for you. Relink it, or edit the connection to run as another of your accounts.`, + recovery: { + action: "Relink the account, or edit the connection to run as another account.", + }, retryable: false, }); expect(execution.value.second.refused).not.toContain(replacement); From 3266d8292e74843f8bf9577b282f120ecfc73f8a Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Mon, 9 Nov 2026 17:32:41 -0800 Subject: [PATCH 4/4] fix: warn when relinking replaces a single-account app link --- e2e/tests/connection-account-unlinked.spec.ts | 25 +++++++++++++++++ .../dashboard/connection-app-picker.tsx | 28 ++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/e2e/tests/connection-account-unlinked.spec.ts b/e2e/tests/connection-account-unlinked.spec.ts index 7938023678..8d1f495402 100644 --- a/e2e/tests/connection-account-unlinked.spec.ts +++ b/e2e/tests/connection-account-unlinked.spec.ts @@ -239,11 +239,36 @@ export default defineApp({ user: { accounts: { service: service${cardinality === }); expect(general.status, JSON.stringify(general.body)).toBe(200); expect(general.body).toEqual([other]); + const relinkStatus = + cardinality === "one" + ? "Needs account — saving replaces Other private label" + : "Needs account — save to relink"; + yield* browser.use("Reopen the connection with another linked account", (page) => + page.reload(), + ); + yield* browser.use("Explain the effect of relinking before saving", (page) => + page.getByText(relinkStatus, { exact: true }).waitFor(), + ); + yield* browser.checkpoint("Relinking with another account already linked"); // Real app authorization still wins, even with the pinned link missing. yield* share("private"); const forbidden = yield* api.request(session, "GET", `${path}/tools`, undefined, scoped); expect(forbidden.status, JSON.stringify(forbidden.body)).toBe(403); expect(forbidden.body).toMatchObject({ _tag: "OrganizationForbidden" }); + yield* share("everyone"); + yield* browser.use("Save with the relinking consequence disclosed", (page) => + page.getByRole("button", { name: "Save changes", exact: true }).click(), + ); + yield* browser.use("Relinking status clears after saving", (page) => + page.getByText(relinkStatus, { exact: true }).waitFor({ state: "hidden" }), + ); + expect(yield* linkedAccountIds(actors.member, path)).toEqual( + cardinality === "one" ? [pinned] : [other, pinned], + ); + expect( + (yield* run("Pinned connection recovers with another account present", call)) + .structuredContent, + ).toMatchObject({ execution: { ok: true, value: [pinned] } }); }).pipe(Effect.provide(McpClient.layer)), ), ); diff --git a/packages/ui/src/implementation/dashboard/connection-app-picker.tsx b/packages/ui/src/implementation/dashboard/connection-app-picker.tsx index 331b73e7ba..2c5cad652b 100644 --- a/packages/ui/src/implementation/dashboard/connection-app-picker.tsx +++ b/packages/ui/src/implementation/dashboard/connection-app-picker.tsx @@ -148,7 +148,32 @@ export function ConnectionAppRow({ // Saved accounts can still be available to choose after their app link was removed. // Saving relinks them, so this is a status/recovery hint, not a validation blocker. const unlinked = selection !== undefined && selectionUnlinked(choices, selection); - const status = issue ?? (unlinked ? "Needs account — save to relink" : undefined); + const replaced = unlinked + ? choices.options.filter( + (option) => + option.linked && + Object.values(app.requirements.accounts).some( + (requirement) => + requirement.scope === "user" && + requirement.cardinality === "one" && + requirement.provider === option.provider, + ) && + selection.targets.some((target) => + choices.options.some( + (chosen) => + chosen.provider === option.provider && + connectionTargetKey(chosen.target) === connectionTargetKey(target), + ), + ), + ) + : []; + const status = + issue ?? + (unlinked + ? replaced.length > 0 + ? `Needs account — saving replaces ${replaced.map((option) => connectionTargetLabel(option.target)).join(", ")}` + : "Needs account — save to relink" + : undefined); const only = choices.options.length === 1 ? choices.options[0] : undefined; const setTools = (tools: ConnectionTools) => { if (selection !== undefined) onChange({ ...selection, tools }); @@ -177,6 +202,7 @@ export function ConnectionAppRow({ {app.name}