Browse Source

Add config to control public sharing (#10105)

Co-authored-by: Roo Code <[email protected]>
Matt Rubens 2 months ago
parent
commit
3502f41e75

+ 5 - 0
packages/cloud/src/CloudService.ts

@@ -337,6 +337,11 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements Di
 		return this.shareService!.canShareTask()
 	}
 
+	public async canSharePublicly(): Promise<boolean> {
+		this.ensureInitialized()
+		return this.shareService!.canSharePublicly()
+	}
+
 	// Lifecycle
 
 	public dispose(): void {

+ 11 - 0
packages/cloud/src/CloudShareService.ts

@@ -47,4 +47,15 @@ export class CloudShareService {
 			return false
 		}
 	}
+
+	async canSharePublicly(): Promise<boolean> {
+		try {
+			const cloudSettings = this.settingsService.getSettings()?.cloudSettings
+			// Public sharing requires both enableTaskSharing AND allowPublicTaskSharing to be true
+			return !!cloudSettings?.enableTaskSharing && cloudSettings?.allowPublicTaskSharing !== false
+		} catch (error) {
+			this.log("[ShareService] Error checking if task can be shared publicly:", error)
+			return false
+		}
+	}
 }

+ 76 - 0
packages/types/src/__tests__/cloud.test.ts

@@ -175,6 +175,82 @@ describe("organizationSettingsSchema with features", () => {
 	})
 })
 
+describe("organizationCloudSettingsSchema with allowPublicTaskSharing", () => {
+	it("should validate without allowPublicTaskSharing property", () => {
+		const input = {
+			recordTaskMessages: true,
+			enableTaskSharing: true,
+		}
+		const result = organizationCloudSettingsSchema.safeParse(input)
+		expect(result.success).toBe(true)
+		expect(result.data?.allowPublicTaskSharing).toBeUndefined()
+	})
+
+	it("should validate with allowPublicTaskSharing as true", () => {
+		const input = {
+			recordTaskMessages: true,
+			enableTaskSharing: true,
+			allowPublicTaskSharing: true,
+		}
+		const result = organizationCloudSettingsSchema.safeParse(input)
+		expect(result.success).toBe(true)
+		expect(result.data?.allowPublicTaskSharing).toBe(true)
+	})
+
+	it("should validate with allowPublicTaskSharing as false", () => {
+		const input = {
+			recordTaskMessages: true,
+			enableTaskSharing: true,
+			allowPublicTaskSharing: false,
+		}
+		const result = organizationCloudSettingsSchema.safeParse(input)
+		expect(result.success).toBe(true)
+		expect(result.data?.allowPublicTaskSharing).toBe(false)
+	})
+
+	it("should reject non-boolean allowPublicTaskSharing", () => {
+		const input = {
+			allowPublicTaskSharing: "true",
+		}
+		const result = organizationCloudSettingsSchema.safeParse(input)
+		expect(result.success).toBe(false)
+	})
+
+	it("should have correct TypeScript type", () => {
+		// Type-only test to ensure TypeScript compilation
+		const settings: OrganizationCloudSettings = {
+			recordTaskMessages: true,
+			enableTaskSharing: true,
+			allowPublicTaskSharing: true,
+		}
+		expect(settings.allowPublicTaskSharing).toBe(true)
+
+		const settingsWithoutPublicSharing: OrganizationCloudSettings = {
+			recordTaskMessages: false,
+		}
+		expect(settingsWithoutPublicSharing.allowPublicTaskSharing).toBeUndefined()
+	})
+
+	it("should validate in organizationSettingsSchema with allowPublicTaskSharing", () => {
+		const input = {
+			version: 1,
+			cloudSettings: {
+				recordTaskMessages: true,
+				enableTaskSharing: true,
+				allowPublicTaskSharing: false,
+			},
+			defaultSettings: {},
+			allowList: {
+				allowAll: true,
+				providers: {},
+			},
+		}
+		const result = organizationSettingsSchema.safeParse(input)
+		expect(result.success).toBe(true)
+		expect(result.data?.cloudSettings?.allowPublicTaskSharing).toBe(false)
+	})
+})
+
 describe("organizationCloudSettingsSchema with workspaceTaskVisibility", () => {
 	it("should validate without workspaceTaskVisibility property", () => {
 		const input = {

+ 2 - 0
packages/types/src/cloud.ts

@@ -135,6 +135,7 @@ export type WorkspaceTaskVisibility = z.infer<typeof workspaceTaskVisibilitySche
 export const organizationCloudSettingsSchema = z.object({
 	recordTaskMessages: z.boolean().optional(),
 	enableTaskSharing: z.boolean().optional(),
+	allowPublicTaskSharing: z.boolean().optional(),
 	taskShareExpirationDays: z.number().int().positive().optional(),
 	allowMembersViewAllTasks: z.boolean().optional(),
 	workspaceTaskVisibility: workspaceTaskVisibilitySchema.optional(),
@@ -209,6 +210,7 @@ export const ORGANIZATION_DEFAULT: OrganizationSettings = {
 	cloudSettings: {
 		recordTaskMessages: true,
 		enableTaskSharing: true,
+		allowPublicTaskSharing: true,
 		taskShareExpirationDays: 30,
 		allowMembersViewAllTasks: true,
 	},

+ 13 - 0
src/core/webview/ClineProvider.ts

@@ -1873,6 +1873,7 @@ export class ClineProvider
 			cloudUserInfo,
 			cloudIsAuthenticated,
 			sharingEnabled,
+			publicSharingEnabled,
 			organizationAllowList,
 			organizationSettingsVersion,
 			maxConcurrentFileReads,
@@ -2025,6 +2026,7 @@ export class ClineProvider
 			cloudIsAuthenticated: cloudIsAuthenticated ?? false,
 			cloudOrganizations,
 			sharingEnabled: sharingEnabled ?? false,
+			publicSharingEnabled: publicSharingEnabled ?? false,
 			organizationAllowList,
 			organizationSettingsVersion,
 			condensingApiConfigId,
@@ -2140,6 +2142,16 @@ export class ClineProvider
 			)
 		}
 
+		let publicSharingEnabled: boolean = false
+
+		try {
+			publicSharingEnabled = await CloudService.instance.canSharePublicly()
+		} catch (error) {
+			console.error(
+				`[getState] failed to get public sharing enabled state: ${error instanceof Error ? error.message : String(error)}`,
+			)
+		}
+
 		let organizationSettingsVersion: number = -1
 
 		try {
@@ -2251,6 +2263,7 @@ export class ClineProvider
 			cloudUserInfo,
 			cloudIsAuthenticated,
 			sharingEnabled,
+			publicSharingEnabled,
 			organizationAllowList,
 			organizationSettingsVersion,
 			condensingApiConfigId: stateValues.condensingApiConfigId,

+ 1 - 0
src/core/webview/__tests__/ClineProvider.spec.ts

@@ -576,6 +576,7 @@ describe("ClineProvider", () => {
 			autoCondenseContextPercent: 100,
 			cloudIsAuthenticated: false,
 			sharingEnabled: false,
+			publicSharingEnabled: false,
 			profileThresholds: {},
 			hasOpenedModeSelector: false,
 			diagnosticsEnabled: true,

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -334,6 +334,7 @@ export type ExtensionState = Pick<
 	cloudApiUrl?: string
 	cloudOrganizations?: CloudOrganizationMembership[]
 	sharingEnabled: boolean
+	publicSharingEnabled: boolean
 	organizationAllowList: OrganizationAllowList
 	organizationSettingsVersion?: number
 

+ 15 - 10
webview-ui/src/components/chat/ShareButton.tsx

@@ -41,6 +41,7 @@ export const ShareButton = ({ item, disabled = false }: ShareButtonProps) => {
 		handleConnect,
 		isAuthenticated: cloudIsAuthenticated,
 		sharingEnabled,
+		publicSharingEnabled,
 	} = useCloudUpsell({
 		onAuthSuccess: () => {
 			// Auto-open share dropdown after successful authentication
@@ -195,17 +196,21 @@ export const ShareButton = ({ item, disabled = false }: ShareButtonProps) => {
 												</div>
 											</CommandItem>
 										)}
-										<CommandItem onSelect={() => handleShare("public")} className="cursor-pointer">
-											<div className="flex items-center gap-2">
-												<span className="codicon codicon-globe text-sm"></span>
-												<div className="flex flex-col">
-													<span className="text-sm">{t("chat:task.sharePublicly")}</span>
-													<span className="text-xs text-vscode-descriptionForeground">
-														{t("chat:task.sharePubliclyDescription")}
-													</span>
+										{publicSharingEnabled && (
+											<CommandItem
+												onSelect={() => handleShare("public")}
+												className="cursor-pointer">
+												<div className="flex items-center gap-2">
+													<span className="codicon codicon-globe text-sm"></span>
+													<div className="flex flex-col">
+														<span className="text-sm">{t("chat:task.sharePublicly")}</span>
+														<span className="text-xs text-vscode-descriptionForeground">
+															{t("chat:task.sharePubliclyDescription")}
+														</span>
+													</div>
 												</div>
-											</div>
-										</CommandItem>
+											</CommandItem>
+										)}
 									</CommandGroup>
 								</CommandList>
 							</Command>

+ 1 - 0
webview-ui/src/components/chat/__tests__/ShareButton.spec.tsx

@@ -20,6 +20,7 @@ vi.mock("@/context/ExtensionStateContext", () => ({
 	ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => children,
 	useExtensionState: () => ({
 		sharingEnabled: true,
+		publicSharingEnabled: true,
 		cloudIsAuthenticated: true,
 		cloudUserInfo: {
 			id: "test-user",

+ 4 - 0
webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx

@@ -81,6 +81,7 @@ describe("TaskActions", () => {
 		vi.clearAllMocks()
 		mockUseExtensionState.mockReturnValue({
 			sharingEnabled: true,
+			publicSharingEnabled: true,
 			cloudIsAuthenticated: true,
 			cloudUserInfo: {
 				organizationName: "Test Organization",
@@ -166,6 +167,7 @@ describe("TaskActions", () => {
 		it("does not show organization option when user is not in an organization", () => {
 			mockUseExtensionState.mockReturnValue({
 				sharingEnabled: true,
+				publicSharingEnabled: true,
 				cloudIsAuthenticated: true,
 				cloudUserInfo: {
 					// No organizationName property
@@ -264,6 +266,7 @@ describe("TaskActions", () => {
 			// Simulate user becoming authenticated (e.g., from CloudView)
 			mockUseExtensionState.mockReturnValue({
 				sharingEnabled: true,
+				publicSharingEnabled: true,
 				cloudIsAuthenticated: true,
 				cloudUserInfo: {
 					organizationName: "Test Organization",
@@ -302,6 +305,7 @@ describe("TaskActions", () => {
 			// Simulate user becoming authenticated after clicking connect from share button
 			mockUseExtensionState.mockReturnValue({
 				sharingEnabled: true,
+				publicSharingEnabled: true,
 				cloudIsAuthenticated: true,
 				cloudUserInfo: {
 					organizationName: "Test Organization",

+ 2 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -43,6 +43,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	cloudIsAuthenticated: boolean
 	cloudOrganizations?: CloudOrganizationMembership[]
 	sharingEnabled: boolean
+	publicSharingEnabled: boolean
 	maxConcurrentFileReads?: number
 	mdmCompliant?: boolean
 	hasOpenedModeSelector: boolean // New property to track if user has opened mode selector
@@ -250,6 +251,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		cloudIsAuthenticated: false,
 		cloudOrganizations: [],
 		sharingEnabled: false,
+		publicSharingEnabled: false,
 		organizationAllowList: ORGANIZATION_ALLOW_ALL,
 		organizationSettingsVersion: -1,
 		autoCondenseContext: true,

+ 1 - 0
webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx

@@ -206,6 +206,7 @@ describe("mergeExtensionState", () => {
 			autoCondenseContextPercent: 100,
 			cloudIsAuthenticated: false,
 			sharingEnabled: false,
+			publicSharingEnabled: false,
 			profileThresholds: {},
 			hasOpenedModeSelector: false, // Add the new required property
 			maxImageFileSize: 5,

+ 2 - 1
webview-ui/src/hooks/useCloudUpsell.ts

@@ -13,7 +13,7 @@ export const useCloudUpsell = (options: UseCloudUpsellOptions = {}) => {
 	const { onAuthSuccess, autoOpenOnAuth = false } = options
 	const [isOpen, setIsOpen] = useState(false)
 	const [shouldOpenOnAuth, setShouldOpenOnAuth] = useState(false)
-	const { cloudIsAuthenticated, sharingEnabled } = useExtensionState()
+	const { cloudIsAuthenticated, sharingEnabled, publicSharingEnabled } = useExtensionState()
 	const wasUnauthenticatedRef = useRef(false)
 	const initiatedAuthRef = useRef(false)
 
@@ -67,5 +67,6 @@ export const useCloudUpsell = (options: UseCloudUpsellOptions = {}) => {
 		handleConnect,
 		isAuthenticated: cloudIsAuthenticated,
 		sharingEnabled,
+		publicSharingEnabled,
 	}
 }