Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/features/connections/ui/ConnectionsSettings.pane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ describe("ConnectionsSettings", () => {
it("renders passive local inventory without managed connections", async () => {
renderConnectionsSettings();
expect(await screen.findByText("GitHub")).toBeInTheDocument();
expect(
screen.queryByText("connections.skillsHint"),
).not.toBeInTheDocument();
expect(
screen.getByText("connections.worksWith:Goose, Codex"),
).toBeInTheDocument();
Expand Down Expand Up @@ -165,6 +168,7 @@ describe("ConnectionsSettings", () => {
await screen.findByText("connections.sections.managed"),
).toBeInTheDocument();
expect(screen.getByText("connections.sections.local")).toBeInTheDocument();
expect(screen.getByText("connections.skillsHint")).toBeInTheDocument();
expect(
screen.queryByText("connections.sections.installed"),
).not.toBeInTheDocument();
Expand Down
6 changes: 6 additions & 0 deletions src/features/connections/ui/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ export function ConnectionsSettings({
}
/>

{showManagedConnections ? (
<p className="text-xs text-muted-foreground">
{t("connections.skillsHint")}
</p>
) : null}

<SearchBar
size="pill"
value={searchTerm}
Expand Down
39 changes: 0 additions & 39 deletions src/features/extensions/lib/reconcileAlwaysOn.ts

This file was deleted.

142 changes: 142 additions & 0 deletions src/features/extensions/lib/reconcileExtensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ExtensionEntry } from "../types";
import { reconcileExtensions } from "./reconcileExtensions";

const { listExtensions, toggleExtension, removeExtension, backupGooseConfig } =
vi.hoisted(() => ({
listExtensions: vi.fn(),
toggleExtension: vi.fn(),
removeExtension: vi.fn(),
backupGooseConfig: vi.fn(),
}));
vi.mock("@/features/extensions/api/extensions", () => ({
listExtensions,
toggleExtension,
removeExtension,
}));
vi.mock("@/features/migration/api/migration", () => ({ backupGooseConfig }));

const legacy: ExtensionEntry = {
type: "stdio",
config_key: "salesforce-sq",
name: "Salesforce (Square)",
description: "",
cmd: "uvx",
args: ["[email protected]"],
bundled: true,
enabled: true,
};

describe("startup extension reconciliation", () => {
beforeEach(() => {
vi.resetAllMocks();
backupGooseConfig.mockResolvedValue({
backedUp: true,
backupPath: "/config.yaml.backup",
});
removeExtension.mockResolvedValue(undefined);
toggleExtension.mockResolvedValue(undefined);
});

it.each([
true,
false,
])("retires the bundled stdio Salesforce MCP when enabled=%s, including after onboarding", async (enabled) => {
listExtensions.mockResolvedValue([{ ...legacy, enabled }]);
await reconcileExtensions();
expect(backupGooseConfig).toHaveBeenCalledOnce();
expect(removeExtension).toHaveBeenCalledWith("salesforce-sq");
expect(listExtensions).toHaveBeenCalledTimes(2);
expect(backupGooseConfig.mock.invocationCallOrder[0]).toBeLessThan(
listExtensions.mock.invocationCallOrder[1],
);
});

it("recognizes uvx paths and normalized package names", async () => {
listExtensions.mockResolvedValue([
{ ...legacy, cmd: "/usr/local/bin/uvx", args: ["mcp-salesforce-sq"] },
]);
await reconcileExtensions();
expect(removeExtension).toHaveBeenCalledWith("salesforce-sq");
});

it("preserves user-owned, remote, credential-configured, and unrelated servers", async () => {
listExtensions.mockResolvedValue([
{ ...legacy, bundled: false },
{ ...legacy, bundled: undefined },
{ ...legacy, type: "streamable_http", uri: "https://example.com/mcp" },
{ ...legacy, envs: { SALESFORCE_TOKEN: "configured" } },
{ ...legacy, env_keys: ["SALESFORCE_TOKEN"] },
{ ...legacy, cmd: "custom-wrapper" },
{ ...legacy, args: ["mcp_salesforce_sq_custom"] },
{ ...legacy, args: ["mcp_salesforce_sq@git+https://example.com/custom"] },
{ ...legacy, args: ["mcp_salesforce_sq", "--custom"] },
]);
await reconcileExtensions();
expect(backupGooseConfig).not.toHaveBeenCalled();
expect(removeExtension).not.toHaveBeenCalled();
});

it.each([
{ current: [{ ...legacy, envs: { SALESFORCE_TOKEN: "configured" } }] },
{ current: [{ ...legacy, env_keys: ["SALESFORCE_TOKEN"] }] },
{ current: [{ ...legacy, args: ["mcp_salesforce_sq", "--custom"] }] },
{ current: [{ ...legacy, name: "My Salesforce" }] },
{ current: [{ ...legacy, config_key: "another-salesforce" }] },
{ current: [] },
])("preserves an entry customized or removed during backup: $current", async ({
current,
}) => {
listExtensions.mockResolvedValueOnce([legacy]).mockResolvedValue(current);
await reconcileExtensions();
expect(backupGooseConfig).toHaveBeenCalledOnce();
expect(removeExtension).not.toHaveBeenCalled();
});

it("revalidates each candidate after the preceding removal", async () => {
const second = { ...legacy, config_key: "other-salesforce" };
listExtensions
.mockResolvedValueOnce([legacy, second])
.mockResolvedValueOnce([legacy, second])
.mockResolvedValueOnce([
{ ...second, envs: { SALESFORCE_TOKEN: "configured" } },
]);
await reconcileExtensions();
expect(removeExtension).toHaveBeenCalledExactlyOnceWith("salesforce-sq");
});

it("enables core tools while retiring Salesforce and becomes a no-op on the next boot", async () => {
listExtensions
.mockResolvedValueOnce([
legacy,
{
type: "builtin",
name: "skills",
description: "",
config_key: "skills",
enabled: false,
},
{
type: "builtin",
name: "developer",
description: "",
config_key: "developer",
enabled: true,
},
])
.mockResolvedValueOnce([legacy])
.mockResolvedValueOnce([]);
await reconcileExtensions();
await reconcileExtensions();
expect(toggleExtension).toHaveBeenCalledExactlyOnceWith("skills", true);
expect(removeExtension).toHaveBeenCalledOnce();
expect(backupGooseConfig).toHaveBeenCalledOnce();
});

it("does not remove anything if the backup fails", async () => {
listExtensions.mockResolvedValue([legacy]);
backupGooseConfig.mockRejectedValue(new Error("backup failed"));
await expect(reconcileExtensions()).rejects.toThrow("backup failed");
expect(removeExtension).not.toHaveBeenCalled();
});
});
61 changes: 61 additions & 0 deletions src/features/extensions/lib/reconcileExtensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
listExtensions,
removeExtension,
toggleExtension,
} from "@/features/extensions/api/extensions";
import { backupGooseConfig } from "@/features/migration/api/migration";
import type { ExtensionEntry } from "../types";
import { KEEP_ENABLED } from "./keepEnabled";

// This bundled stdio server expects an HTTP request for hosted authentication.
// Keep user-owned servers and configurations that supply their own token.
function isLegacySalesforceMcp(extension: ExtensionEntry): boolean {
return (
extension.type === "stdio" &&
extension.bundled === true &&
extension.cmd.split(/[\\/]/).at(-1) === "uvx" &&
extension.args.length === 1 &&
/^mcp[-_]salesforce[-_]sq(?:@\d+(?:\.\d+){1,2}(?:[a-zA-Z0-9.+-]*))?$/.test(
extension.args[0],
) &&
!Object.hasOwn(extension.envs ?? {}, "SALESFORCE_TOKEN") &&
!extension.env_keys?.includes("SALESFORCE_TOKEN")
);
}

/**
* Reconcile extension policy on every boot, including already-migrated installs.
* Core tools stay enabled. Retired bundled Salesforce MCPs are backed up and
* removed so the extension manager cannot load them on demand.
* Startup callers log failures and continue, allowing a retry on the next boot.
*/
export async function reconcileExtensions(): Promise<void> {
const extensions = await listExtensions();
for (const extension of extensions) {
if (!KEEP_ENABLED.has(extension.config_key) || extension.enabled) continue;
try {
await toggleExtension(extension.config_key, true);
} catch (error) {
console.warn(
`Failed to re-enable always-on extension '${extension.config_key}':`,
error,
);
}
}

const retired = extensions.filter(isLegacySalesforceMcp);
Comment thread
johnmatthewtennant marked this conversation as resolved.
if (retired.length === 0) return;
await backupGooseConfig();
for (const extension of retired) {
const current = (await listExtensions()).find(
(entry) => entry.config_key === extension.config_key,
);
if (
current &&
isLegacySalesforceMcp(current) &&
JSON.stringify(current) === JSON.stringify(extension)
) {
await removeExtension(current.config_key);
}
}
}
18 changes: 7 additions & 11 deletions src/features/migration/hooks/useMigrationGate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { reconcileAlwaysOnExtensions } from "@/features/extensions/lib/reconcileAlwaysOn";
import { reconcileExtensions } from "@/features/extensions/lib/reconcileExtensions";
import { cleanupLegacyBundledExtensions } from "../cleanupLegacyBundledExtensions";
import {
getMigrationStatus,
Expand Down Expand Up @@ -81,16 +81,12 @@ export function useMigrationGate(startupReady: boolean): MigrationGate {
}
}

// Heal extensions whose desired state has changed since the user's
// migration ran (e.g. a newly-added always-on entry). Best-effort —
// failures don't block startup.
// Apply current extension policy even after onboarding has completed.
// Best-effort: failures do not block startup.
try {
await reconcileAlwaysOnExtensions();
await reconcileExtensions();
} catch (reconcileError) {
console.warn(
"Failed to reconcile always-on extensions:",
reconcileError,
);
console.warn("Failed to reconcile extensions:", reconcileError);
}
if (cancelled) return;
setStoreStatus(latestStatus);
Expand Down Expand Up @@ -129,10 +125,10 @@ export function useMigrationGate(startupReady: boolean): MigrationGate {
if (cancelled) return;

try {
await reconcileAlwaysOnExtensions();
await reconcileExtensions();
} catch (reconcileError) {
console.warn(
"Failed to reconcile always-on extensions after migration:",
"Failed to reconcile extensions after migration:",
reconcileError,
);
}
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"extendAccess": "Extend access",
"noResults": "No connections match your search.",
"reconnect": "Reconnect",
"skillsHint": "Connect an app here to authorize access. Find and install its skill in the skill marketplace so agents know how to use it.",
"search": "Search connections",
"sections": {
"managed": "Company managed",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"extendAccess": "Ampliar acceso",
"noResults": "Ninguna conexión coincide con tu búsqueda.",
"reconnect": "Reconectar",
"skillsHint": "Conecta una aplicación aquí para autorizar el acceso. Busca e instala su habilidad en el catálogo de habilidades para que los agentes sepan cómo usarla.",
"search": "Buscar conexiones",
"sections": {
"managed": "Administradas por la empresa",
Expand Down
Loading